mirror of
https://git.sanhost.net/sanasol/hytale-f2p.git
synced 2026-02-26 14:51:48 -03:00
Compare commits
232 Commits
v2.0.1-mac
...
fix/steamd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc664afa52 | ||
|
|
2efecd168f | ||
|
|
225bc662b3 | ||
|
|
8ef13c5ee1 | ||
|
|
778ed11f87 | ||
|
|
24a919588e | ||
|
|
219b50a214 | ||
|
|
4c059f0a6b | ||
|
|
9025800820 | ||
|
|
34ee099ae2 | ||
|
|
e56b12cd72 | ||
|
|
3edee4b4eb | ||
|
|
e5fec7c326 | ||
|
|
7d2672b684 | ||
|
|
01823729ec | ||
|
|
639a2ab1b5 | ||
|
|
6b76eb365e | ||
|
|
6fa933fece | ||
|
|
e7023dcf95 | ||
|
|
faf21b830b | ||
|
|
f4d966ee65 | ||
|
|
ca835a868b | ||
|
|
3a1b6039d0 | ||
|
|
7828454631 | ||
|
|
cc1c6c334c | ||
|
|
081ac926e3 | ||
|
|
75a450c9ec | ||
|
|
e426690632 | ||
|
|
78f76afe0a | ||
|
|
131de1dcd7 | ||
|
|
b39877f561 | ||
|
|
6f10b1390d | ||
|
|
0b1b448cce | ||
|
|
aed00cd067 | ||
|
|
c4a32ce1e0 | ||
|
|
eff6fcd520 | ||
|
|
94d4586b97 | ||
|
|
20faf36b37 | ||
|
|
375b422c73 | ||
|
|
b668bdb45a | ||
|
|
653d4429ed | ||
|
|
17e15c17f0 | ||
|
|
b99b22e8bf | ||
|
|
9303c17e57 | ||
|
|
615ee5cadc | ||
|
|
7a9a67d8e8 | ||
|
|
4c854953fe | ||
|
|
4cd0539ce3 | ||
|
|
fa2d451f90 | ||
|
|
a4faa7138c | ||
|
|
d285dc7517 | ||
|
|
ceadd69eea | ||
|
|
6f0dd27c1d | ||
|
|
ba95187ee6 | ||
|
|
9e54e07b22 | ||
|
|
a8e7e57c86 | ||
|
|
d1ab58d51b | ||
|
|
8781025df9 | ||
|
|
81c52e9507 | ||
|
|
45314620e4 | ||
|
|
43d5d20351 | ||
|
|
72b4e0cba8 | ||
|
|
25d5131a7b | ||
|
|
ad3c73563d | ||
|
|
f0f19f690f | ||
|
|
b27860a655 | ||
|
|
9788d0e496 | ||
|
|
2a5780c2d4 | ||
|
|
8263b3f99b | ||
|
|
db56ef1624 | ||
|
|
35f900d6ab | ||
|
|
e1d1383ab7 | ||
|
|
8326deddb1 | ||
|
|
b11b78f7dc | ||
|
|
62a2d76e4a | ||
|
|
0ca8b4e02f | ||
|
|
c6a9d0ae07 | ||
|
|
f438d6c8e0 | ||
|
|
f07e4a2004 | ||
|
|
131580d3ba | ||
|
|
084347db03 | ||
|
|
589c5b457f | ||
|
|
790d4d3f29 | ||
|
|
52313910dc | ||
|
|
a3f4d8e9d8 | ||
|
|
86d617a4d3 | ||
|
|
0a97ac95fc | ||
|
|
b94b45681b | ||
|
|
4086612e9d | ||
|
|
e7fca5a4c7 | ||
|
|
e7bd20a1ec | ||
|
|
151b017653 | ||
|
|
da3e14c434 | ||
|
|
6302734eeb | ||
|
|
07191860be | ||
|
|
2f767f191e | ||
|
|
de9c7d81f5 | ||
|
|
4c3277392e | ||
|
|
f287cb55b9 | ||
|
|
d87db04653 | ||
|
|
67aa41aefe | ||
|
|
bd1dd146a9 | ||
|
|
c8d7707b70 | ||
|
|
127c38f98b | ||
|
|
f974d9c767 | ||
|
|
7e4a45e466 | ||
|
|
ea21fb15d6 | ||
|
|
3d54cea9e7 | ||
|
|
9f43a32779 | ||
|
|
9c8a12f25c | ||
|
|
a7d0523186 | ||
|
|
a6f716c61b | ||
|
|
ca8ed171d1 | ||
|
|
679799c074 | ||
|
|
87b168dd4c | ||
|
|
679f065e24 | ||
|
|
ecae7d2ee5 | ||
|
|
fa50fec34d | ||
|
|
c900129c1f | ||
|
|
6b75858515 | ||
|
|
61bcdf9413 | ||
|
|
411d7d8aaf | ||
|
|
8a87c7c4d9 | ||
|
|
34f93e962b | ||
|
|
d8393543df | ||
|
|
b62ffc126e | ||
|
|
3579d82776 | ||
|
|
b5c6c38d92 | ||
|
|
f932462578 | ||
|
|
e005b4293b | ||
|
|
e43897f816 | ||
|
|
3983fdb1bc | ||
|
|
b46ce93af7 | ||
|
|
2a87acfe46 | ||
|
|
a2e2d5e5fd | ||
|
|
34143d9872 | ||
|
|
08c2218cf8 | ||
|
|
032418b7f7 | ||
|
|
fc05725a43 | ||
|
|
203a56879f | ||
|
|
7a0065ea2b | ||
|
|
ac08eb50ff | ||
|
|
70fe4203ef | ||
|
|
f433120084 | ||
|
|
f4099acbed | ||
|
|
da843257c1 | ||
|
|
e4576042be | ||
|
|
71974e031f | ||
|
|
5bd52f09db | ||
|
|
1ba6b22b74 | ||
|
|
a1bc88b754 | ||
|
|
24c2371b50 | ||
|
|
4c6e1a616e | ||
|
|
b54eb4e834 | ||
|
|
a1c74e4175 | ||
|
|
260e6c1126 | ||
|
|
6eb628559b | ||
|
|
052b5dc7dc | ||
|
|
7e9b5046df | ||
|
|
204d6b21f6 | ||
|
|
740d516cfe | ||
|
|
ce052add0d | ||
|
|
d7a904c641 | ||
|
|
d5d2f60c97 | ||
|
|
61433bfeea | ||
|
|
9eb5d1759c | ||
|
|
68d697576a | ||
|
|
a8da559e93 | ||
|
|
828d05ca33 | ||
|
|
75f9403888 | ||
|
|
b61c94d348 | ||
|
|
c0109575d6 | ||
|
|
2a024b61dd | ||
|
|
1c39e8e4c6 | ||
|
|
753bd4fd61 | ||
|
|
cefb4c5575 | ||
|
|
1c779e0e41 | ||
|
|
bb474fe233 | ||
|
|
917f5f455b | ||
|
|
1dd42bdc79 | ||
|
|
7cfe3edd32 | ||
|
|
eb22758ab9 | ||
|
|
42fd51486a | ||
|
|
9ef05e8322 | ||
|
|
4ac12e0e24 | ||
|
|
72a151930e | ||
|
|
a9644b8c64 | ||
|
|
9fc238e103 | ||
|
|
b62e94a415 | ||
|
|
3e82e8fadb | ||
|
|
a355133ccf | ||
|
|
ff5acb5278 | ||
|
|
5d75ca80aa | ||
|
|
281aa6fcde | ||
|
|
158d0af820 | ||
|
|
503f304704 | ||
|
|
234e1e1008 | ||
|
|
d002e831cd | ||
|
|
048f2040f1 | ||
|
|
b05aeef66d | ||
|
|
30265549cf | ||
|
|
5a3efba1d6 | ||
|
|
479f24e86f | ||
|
|
611b7a7084 | ||
|
|
e472435927 | ||
|
|
f40d0105df | ||
|
|
96db9adf68 | ||
|
|
a0f49f126c | ||
|
|
f8333c09cd | ||
|
|
bcf0326763 | ||
|
|
0a5c3db710 | ||
|
|
905a9d754c | ||
|
|
99f032e9ab | ||
|
|
5e3506a9ac | ||
|
|
f2a05d2079 | ||
|
|
cece338609 | ||
|
|
261582a882 | ||
|
|
0f0f0fa308 | ||
|
|
5e6a07f0a6 | ||
|
|
713377fdc6 | ||
|
|
2671a59f38 | ||
|
|
8e9af9c768 | ||
|
|
e962a8880d | ||
|
|
c2d5536dd0 | ||
|
|
727be2ca5c | ||
|
|
64892c81e9 | ||
|
|
fffc730788 | ||
|
|
c475ec7879 | ||
|
|
7efa0d07b0 | ||
|
|
21f8527ed4 | ||
|
|
300616ba82 | ||
|
|
9fdd6f1f44 |
0
.env.example
Normal file
0
.env.example
Normal file
83
.github/CODE_OF_CONDUCT.md
vendored
Normal file
83
.github/CODE_OF_CONDUCT.md
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
70
.github/CONTRIBUTING.md
vendored
Normal file
70
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Contributing to Hytale F2P
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to Hytale F2P! We welcome contributions from everyone. By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
## How to Contribute
|
||||||
|
|
||||||
|
### Reporting Bugs
|
||||||
|
|
||||||
|
- Use the [Bug Report](.github/ISSUE_TEMPLATE/bug_report.yml) template
|
||||||
|
- Include as much detail as possible
|
||||||
|
- Include screenshots if applicable
|
||||||
|
- Check if the issue has already been reported
|
||||||
|
|
||||||
|
### Suggesting Features
|
||||||
|
|
||||||
|
- Use the [Feature Request](.github/ISSUE_TEMPLATE/feature_request.yml) template
|
||||||
|
- Clearly describe the feature and its benefits
|
||||||
|
- Consider if the feature aligns with the project's goals
|
||||||
|
|
||||||
|
### Contributing Code
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch: `git checkout -b feature/your-feature-name`
|
||||||
|
3. Make your changes
|
||||||
|
4. Write tests if applicable
|
||||||
|
5. Ensure all tests pass
|
||||||
|
6. Update documentation if needed
|
||||||
|
7. Commit your changes: `git commit -m 'Add some feature'`
|
||||||
|
8. Push to the branch: `git push origin feature/your-feature-name`
|
||||||
|
9. Submit a pull request
|
||||||
|
|
||||||
|
### Pull Request Process
|
||||||
|
|
||||||
|
- Use the appropriate [Pull Request template](.github/PULL_REQUEST_TEMPLATE/)
|
||||||
|
- Ensure your PR description clearly describes the changes
|
||||||
|
- Link to any related issues
|
||||||
|
- Wait for review and address any feedback
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
1. Clone the repository: `git clone https://github.com/your-username/hytale-f2p.git`
|
||||||
|
2. Install dependencies: `npm install` (or appropriate command)
|
||||||
|
3. Set up your development environment
|
||||||
|
4. Run tests: `npm test`
|
||||||
|
5. Start development server: `npm run dev`
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Follow the existing code style in the project
|
||||||
|
- Use meaningful variable and function names
|
||||||
|
- Write clear, concise comments
|
||||||
|
- Keep functions small and focused
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Write unit tests for new features
|
||||||
|
- Ensure all existing tests pass
|
||||||
|
- Test on multiple platforms/browsers if applicable
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Update README.md if needed
|
||||||
|
- Document new features or changes
|
||||||
|
- Keep documentation up to date
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
If you have questions about contributing, feel free to ask in our [Discussions](https://github.com/your-username/hytale-f2p/discussions) or create a [Support Request](.github/ISSUE_TEMPLATE/support_request.yml).
|
||||||
|
|
||||||
|
Thank you for contributing to Hytale F2P!
|
||||||
54
.github/ISSUE_TEMPLATE/assets_contribution.yml
vendored
Normal file
54
.github/ISSUE_TEMPLATE/assets_contribution.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
name: Asset Contribution
|
||||||
|
description: Contribute assets (images, sounds, models, etc.)
|
||||||
|
title: "[ASSETS] "
|
||||||
|
labels: ["assets"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Asset Description
|
||||||
|
description: Describe the asset(s) you're contributing.
|
||||||
|
placeholder: "What type of asset is this? What does it represent?"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: format
|
||||||
|
attributes:
|
||||||
|
label: File Format
|
||||||
|
description: What format are the asset files in?
|
||||||
|
placeholder: "e.g. PNG, JPG, MP3, OBJ"
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: license
|
||||||
|
attributes:
|
||||||
|
label: License
|
||||||
|
description: What license applies to this asset?
|
||||||
|
placeholder: "e.g. CC0, MIT, Public Domain"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: usage
|
||||||
|
attributes:
|
||||||
|
label: Intended Usage
|
||||||
|
description: Where and how should this asset be used in the project?
|
||||||
|
placeholder: "This asset should be used for..., in the following context..."
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: source
|
||||||
|
attributes:
|
||||||
|
label: Source/Attribution
|
||||||
|
description: If this asset is derived from another source, provide attribution.
|
||||||
|
placeholder: "Created by me, or derived from [source]"
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: link
|
||||||
|
attributes:
|
||||||
|
label: Download Link
|
||||||
|
description: Provide a link to download or view the asset.
|
||||||
|
placeholder: "GitHub release, Google Drive, etc."
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Information
|
||||||
|
description: Any other information about the asset.
|
||||||
96
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
96
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
name: Bug Report
|
||||||
|
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:
|
||||||
|
label: Describe the bug
|
||||||
|
description: A clear and concise description of what the bug is.
|
||||||
|
placeholder: "Tell us what you see! The more detail the better."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce
|
||||||
|
attributes:
|
||||||
|
label: To Reproduce
|
||||||
|
description: Steps to reproduce the behavior
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: A clear and concise description of what you expected to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: What version of the launcher are you running?
|
||||||
|
placeholder: "e.g. \"v2.0.11 stable/pre-release\""
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: hardwarespec
|
||||||
|
attributes:
|
||||||
|
label: Hardware Specification
|
||||||
|
description: Tell us your CPU, iGPU, dGPU, VRAM, and RAM information.
|
||||||
|
placeholder: "CPU: Intel i9-14900K 6.0 GHz | GPU: NVIDIA RTX 4090 | VRAM: 24 GB | RAM: 32 GB"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Operating System
|
||||||
|
description: What operating system are you using?
|
||||||
|
options:
|
||||||
|
- 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
|
||||||
|
attributes:
|
||||||
|
label: Logs or Error Messages
|
||||||
|
description: If applicable, paste any error messages or logs here.
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context about the problem here.
|
||||||
52
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
52
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
title: "[FEATURE] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
description: Brief explanation of the feature.
|
||||||
|
placeholder: "Describe in a few sentences what this feature would do."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
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.
|
||||||
|
placeholder: "Ex. I'm always frustrated when [...]"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Describe the solution you'd like
|
||||||
|
description: A clear and concise description of what you want to happen.
|
||||||
|
placeholder: "Describe what you want to happen."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
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: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots (Optional)
|
||||||
|
description: If applicable, add screenshots to help explain your request.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context or screenshots about the feature request here.
|
||||||
24
.github/ISSUE_TEMPLATE/new_translation_request.yml
vendored
Normal file
24
.github/ISSUE_TEMPLATE/new_translation_request.yml
vendored
Normal 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
|
||||||
61
.github/ISSUE_TEMPLATE/security_vulnerability.yml
vendored
Normal file
61
.github/ISSUE_TEMPLATE/security_vulnerability.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: Security Vulnerability
|
||||||
|
description: Report a security vulnerability
|
||||||
|
title: "[SECURITY] "
|
||||||
|
labels: ["security"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thank you for reporting a security vulnerability. Please review our [Security Policy](SECURITY.md) for more information on how we handle security issues.
|
||||||
|
|
||||||
|
If you are reporting a security vulnerability, please provide as much detail as possible so we can assess and address it promptly.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
description: Brief description of the security issue.
|
||||||
|
placeholder: "Describe the security vulnerability in a few sentences."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: details
|
||||||
|
attributes:
|
||||||
|
label: Vulnerability Details
|
||||||
|
description: Detailed description of the vulnerability, including how it can be exploited.
|
||||||
|
placeholder: "Provide detailed steps, code snippets, or other information that demonstrates the vulnerability."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: Impact
|
||||||
|
description: What is the potential impact of this vulnerability?
|
||||||
|
placeholder: "Describe the potential consequences if this vulnerability is exploited."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: mitigation
|
||||||
|
attributes:
|
||||||
|
label: Suggested Mitigation
|
||||||
|
description: Any suggestions for fixing or mitigating the issue.
|
||||||
|
placeholder: "Provide any suggestions for how to fix or mitigate this vulnerability."
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: contact
|
||||||
|
attributes:
|
||||||
|
label: Contact Information (Optional)
|
||||||
|
description: How can we contact you for more information?
|
||||||
|
placeholder: "Email address or other contact method"
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: terms
|
||||||
|
attributes:
|
||||||
|
label: Terms
|
||||||
|
description: By submitting this issue, you agree to our responsible disclosure terms.
|
||||||
|
options:
|
||||||
|
- label: I understand that this is a private security report and will not publicly disclose details until the issue is resolved.
|
||||||
|
required: true
|
||||||
78
.github/ISSUE_TEMPLATE/support_request.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/support_request.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: Support Request
|
||||||
|
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:
|
||||||
|
label: What do you need help with?
|
||||||
|
description: Describe your question or issue clearly.
|
||||||
|
placeholder: "I'm having trouble with..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Context
|
||||||
|
description: Provide any relevant context or background information.
|
||||||
|
placeholder: "I've tried..., but got..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: hardwarespec
|
||||||
|
attributes:
|
||||||
|
label: Hardware Specification
|
||||||
|
description: Tell us your CPU, iGPU, dGPU, VRAM, and RAM information.
|
||||||
|
placeholder: "CPU: Intel i9-14900K 6.0 GHz | GPU: NVIDIA RTX 4090 | VRAM: 24 GB | RAM: 32 GB"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: What version are you using?
|
||||||
|
placeholder: "e.g. v2.0.11 stable/pre-release"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
description: What platform are you using?
|
||||||
|
options:
|
||||||
|
- 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
|
||||||
|
attributes:
|
||||||
|
label: Logs or Error Messages
|
||||||
|
description: If applicable, paste any error messages or logs here.
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Information
|
||||||
|
description: Any other information that might help us assist you.
|
||||||
41
.github/ISSUE_TEMPLATE/translation_fix_request.yml
vendored
Normal file
41
.github/ISSUE_TEMPLATE/translation_fix_request.yml
vendored
Normal 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.
|
||||||
42
.github/ISSUE_TEMPLATE/translation_request.yml
vendored
Normal file
42
.github/ISSUE_TEMPLATE/translation_request.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Translation Request
|
||||||
|
description: Request translation for text or content
|
||||||
|
title: "[TRANSLATION] "
|
||||||
|
labels: ["translation"]
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
id: language
|
||||||
|
attributes:
|
||||||
|
label: Target Language
|
||||||
|
description: What language do you want to translate to?
|
||||||
|
placeholder: "e.g. Spanish (es-ES), French (fr-FR)"
|
||||||
|
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: input
|
||||||
|
id: file_location
|
||||||
|
attributes:
|
||||||
|
label: File Location
|
||||||
|
description: Where is this text located in the codebase?
|
||||||
|
placeholder: "e.g. src/components/Button.js:15"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: notes
|
||||||
|
attributes:
|
||||||
|
label: Additional Notes
|
||||||
|
description: Any specific instructions or notes for the translator.
|
||||||
24
.github/PULL_REQUEST_TEMPLATE/bug_fix.md
vendored
Normal file
24
.github/PULL_REQUEST_TEMPLATE/bug_fix.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
## Description
|
||||||
|
Brief description of the bug fix.
|
||||||
|
|
||||||
|
## Related Issue
|
||||||
|
Fixes # (issue number)
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
- List the changes made to fix the bug
|
||||||
|
- Be specific about what was changed and why
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- How did you test the fix?
|
||||||
|
- What scenarios were covered?
|
||||||
|
|
||||||
|
## Screenshots (if applicable)
|
||||||
|
Add screenshots to demonstrate the fix.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] My code follows the project's style guidelines
|
||||||
|
- [ ] I have performed a self-review of my own code
|
||||||
|
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||||
|
- [ ] My changes generate no new warnings
|
||||||
|
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||||
|
- [ ] New and existing unit tests pass locally with my changes
|
||||||
16
.github/PULL_REQUEST_TEMPLATE/documentation.md
vendored
Normal file
16
.github/PULL_REQUEST_TEMPLATE/documentation.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
## Description
|
||||||
|
Brief description of the documentation changes.
|
||||||
|
|
||||||
|
## Related Issue
|
||||||
|
Addresses # (issue number)
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
- List the documentation files that were added, updated, or removed
|
||||||
|
- Describe what information was added or corrected
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Documentation is clear and easy to understand
|
||||||
|
- [ ] Links and references are correct
|
||||||
|
- [ ] Code examples (if any) are accurate and functional
|
||||||
|
- [ ] Spelling and grammar are correct
|
||||||
|
- [ ] Documentation follows the project's style guidelines
|
||||||
26
.github/PULL_REQUEST_TEMPLATE/hotfix.md
vendored
Normal file
26
.github/PULL_REQUEST_TEMPLATE/hotfix.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
## Description
|
||||||
|
Brief description of the hotfix.
|
||||||
|
|
||||||
|
## Related Issue
|
||||||
|
Fixes # (issue number) - URGENT
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
- List the minimal changes made to fix the critical issue
|
||||||
|
- Be specific about what was changed
|
||||||
|
|
||||||
|
## Urgency
|
||||||
|
Why is this a hotfix? (Critical bug, security issue, production down, etc.)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- How was the hotfix tested?
|
||||||
|
- What was the minimal testing performed?
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
- Any special deployment considerations?
|
||||||
|
- Rollback plan if needed?
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] This is a minimal change addressing only the critical issue
|
||||||
|
- [ ] No new features or unrelated changes included
|
||||||
|
- [ ] Basic functionality verified
|
||||||
|
- [ ] Ready for immediate deployment
|
||||||
20
.github/PULL_REQUEST_TEMPLATE/localization.md
vendored
Normal file
20
.github/PULL_REQUEST_TEMPLATE/localization.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
## Description
|
||||||
|
Brief description of the localization changes.
|
||||||
|
|
||||||
|
## Related Issue
|
||||||
|
Addresses # (issue number)
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
- List the languages and files that were updated
|
||||||
|
- Describe what text was translated or updated
|
||||||
|
|
||||||
|
## Languages Updated
|
||||||
|
- Language 1 (locale code)
|
||||||
|
- Language 2 (locale code)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Translations are accurate and culturally appropriate
|
||||||
|
- [ ] Placeholder variables (%s, %d, etc.) are preserved
|
||||||
|
- [ ] Text length is appropriate for UI elements
|
||||||
|
- [ ] No hardcoded strings remain
|
||||||
|
- [ ] Localization files are properly formatted
|
||||||
25
.github/PULL_REQUEST_TEMPLATE/new_feature.md
vendored
Normal file
25
.github/PULL_REQUEST_TEMPLATE/new_feature.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
## Description
|
||||||
|
Brief description of the new feature.
|
||||||
|
|
||||||
|
## Related Issue
|
||||||
|
Addresses # (issue number)
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
- List the changes made to implement the feature
|
||||||
|
- Be specific about new files, modified files, and functionality added
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- How did you test the new feature?
|
||||||
|
- What scenarios were covered?
|
||||||
|
|
||||||
|
## Screenshots (if applicable)
|
||||||
|
Add screenshots to demonstrate the new feature.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] My code follows the project's style guidelines
|
||||||
|
- [ ] I have performed a self-review of my own code
|
||||||
|
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||||
|
- [ ] My changes generate no new warnings
|
||||||
|
- [ ] I have added tests that prove my feature works
|
||||||
|
- [ ] New and existing unit tests pass locally with my changes
|
||||||
|
- [ ] I have updated the documentation accordingly
|
||||||
27
.github/PULL_REQUEST_TEMPLATE/refactor.md
vendored
Normal file
27
.github/PULL_REQUEST_TEMPLATE/refactor.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
## Description
|
||||||
|
Brief description of the refactoring changes.
|
||||||
|
|
||||||
|
## Related Issue
|
||||||
|
Addresses # (issue number)
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
- List the refactored code sections
|
||||||
|
- Describe what was improved (readability, performance, maintainability, etc.)
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
Why was this refactoring necessary?
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Does this change affect any APIs or interfaces?
|
||||||
|
- Are there any breaking changes?
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- How was the refactored code tested?
|
||||||
|
- Did existing tests pass?
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Code is more readable and maintainable
|
||||||
|
- [ ] No functionality was broken
|
||||||
|
- [ ] Performance was not negatively impacted
|
||||||
|
- [ ] All existing tests pass
|
||||||
|
- [ ] New tests were added if necessary
|
||||||
18
.github/README1.md
vendored
18
.github/README1.md
vendored
@@ -22,13 +22,25 @@ All builds run in parallel:
|
|||||||
|
|
||||||
### Creating a Release
|
### Creating a Release
|
||||||
|
|
||||||
1. Update version in `package.json`
|
**⚠️ IMPORTANT: Semantic Versioning Required**
|
||||||
|
|
||||||
|
This project uses **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`
|
||||||
|
|
||||||
|
**Format**: `MAJOR.MINOR.PATCH` (e.g., `2.0.11`)
|
||||||
|
|
||||||
|
The auto-update system requires semantic versioning for proper version comparison. Letter suffixes are not supported.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Update version in `package.json` (use numerical format only, e.g., `2.0.11`)
|
||||||
2. Commit and push to `main`
|
2. Commit and push to `main`
|
||||||
3. Create and push a version tag:
|
3. Create and push a version tag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag v2.0.1
|
git tag v2.0.11
|
||||||
git push origin v2.0.1
|
git push origin v2.0.11
|
||||||
```
|
```
|
||||||
|
|
||||||
The workflow will:
|
The workflow will:
|
||||||
|
|||||||
55
.github/SECURITY.md
vendored
Normal file
55
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
We take security seriously. The following versions of our project are currently being supported with security updates:
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 1.x.x | :white_check_mark: |
|
||||||
|
| < 1.0 | :x: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you discover a security vulnerability, please report it to us as follows:
|
||||||
|
|
||||||
|
**Do not report security vulnerabilities through public GitHub issues.**
|
||||||
|
|
||||||
|
Instead, please report security vulnerabilities by:
|
||||||
|
|
||||||
|
1. Using the [Security Vulnerability Report](.github/ISSUE_TEMPLATE/security_vulnerability.yml) template (this creates a private issue)
|
||||||
|
2. Emailing [security@yourdomain.com](mailto:security@yourdomain.com) (if available)
|
||||||
|
3. Contacting the maintainers directly through secure channels
|
||||||
|
|
||||||
|
## What to Include in Your Report
|
||||||
|
|
||||||
|
Please include the following information in your report:
|
||||||
|
|
||||||
|
- A clear description of the vulnerability
|
||||||
|
- Steps to reproduce the issue
|
||||||
|
- Potential impact of the vulnerability
|
||||||
|
- Any suggested fixes or mitigations
|
||||||
|
- Your contact information for follow-up
|
||||||
|
|
||||||
|
## Our Response Process
|
||||||
|
|
||||||
|
1. **Acknowledgment**: We will acknowledge receipt of your report within 48 hours
|
||||||
|
2. **Investigation**: We will investigate the issue and work on a fix
|
||||||
|
3. **Updates**: We will provide regular updates on our progress
|
||||||
|
4. **Resolution**: Once fixed, we will notify you and publicly disclose the issue (with your permission)
|
||||||
|
|
||||||
|
## Responsible Disclosure
|
||||||
|
|
||||||
|
We kindly ask that you:
|
||||||
|
|
||||||
|
- Give us reasonable time to fix the issue before public disclosure
|
||||||
|
- Avoid accessing or modifying user data
|
||||||
|
- Avoid denial-of-service attacks or other disruptive actions
|
||||||
|
|
||||||
|
## Recognition
|
||||||
|
|
||||||
|
We appreciate security researchers who help keep our project safe. With your permission, we will acknowledge your contribution in our security advisories.
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
If you have questions about our security policy, please contact us through the methods listed above.
|
||||||
138
.github/workflows/release.yml
vendored
138
.github/workflows/release.yml
vendored
@@ -9,24 +9,6 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-linux:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'npm'
|
|
||||||
- run: npm install
|
|
||||||
- run: npm ci
|
|
||||||
- run: npx electron-builder --linux --publish never
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: linux-builds
|
|
||||||
path: |
|
|
||||||
dist/*.AppImage
|
|
||||||
dist/*.deb
|
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -35,14 +17,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- run: npm install
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx electron-builder --win --publish never
|
|
||||||
|
- name: Build Windows Packages
|
||||||
|
run: npx electron-builder --win --publish never
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-builds
|
name: windows-builds
|
||||||
path: |
|
path: |
|
||||||
dist/*.exe
|
dist/*.exe
|
||||||
|
dist/*.exe.blockmap
|
||||||
|
dist/latest.yml
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
@@ -52,9 +37,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- run: npm install
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx electron-builder --mac --publish never
|
|
||||||
|
- name: Build macOS Packages
|
||||||
|
run: npx electron-builder --mac --publish never
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos-builds
|
name: macos-builds
|
||||||
@@ -63,15 +49,96 @@ jobs:
|
|||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/latest-mac.yml
|
dist/latest-mac.yml
|
||||||
|
|
||||||
release:
|
build-linux:
|
||||||
needs: [build-linux, build-windows, build-macos]
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libarchive-tools
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Build Linux Packages
|
||||||
|
run: |
|
||||||
|
npx electron-builder --linux AppImage deb rpm --x64 --arm64 --publish never
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux-builds
|
||||||
|
path: |
|
||||||
|
dist/*.AppImage
|
||||||
|
dist/*.AppImage.blockmap
|
||||||
|
dist/*.deb
|
||||||
|
dist/*.rpm
|
||||||
|
dist/latest-linux.yml
|
||||||
|
|
||||||
|
build-arch:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: archlinux:latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install base packages
|
||||||
|
run: |
|
||||||
|
pacman -Syu --noconfirm
|
||||||
|
pacman -S --noconfirm \
|
||||||
|
base-devel \
|
||||||
|
git \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
rpm-tools \
|
||||||
|
libxcrypt-compat
|
||||||
|
|
||||||
|
- name: Create build user
|
||||||
|
run: |
|
||||||
|
useradd -m builder
|
||||||
|
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||||
|
|
||||||
|
- name: Fix Permissions
|
||||||
|
run: chown -R builder:builder .
|
||||||
|
|
||||||
|
- name: Build Arch Package
|
||||||
|
run: |
|
||||||
|
sudo -u builder bash << 'EOF'
|
||||||
|
set -e
|
||||||
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
|
makepkg -s --noconfirm
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Upload Arch Package
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: arch-package
|
||||||
|
path: |
|
||||||
|
*.pkg.tar.zst
|
||||||
|
*.src.tar.zst
|
||||||
|
.SRCINFO
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [build-windows, build-macos, build-linux, build-arch]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: |
|
||||||
|
startsWith(github.ref, 'refs/tags/v') ||
|
||||||
|
github.ref == 'refs/heads/main' ||
|
||||||
|
github.event_name == 'workflow_dispatch'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# FIX: './package.json' Module Not Found in `Get version` step
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -80,13 +147,26 @@ jobs:
|
|||||||
- name: Display structure of downloaded files
|
- name: Display structure of downloaded files
|
||||||
run: ls -R artifacts
|
run: ls -R artifacts
|
||||||
|
|
||||||
|
- name: Get version from package.json
|
||||||
|
id: pkg_version
|
||||||
|
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
# 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) }}
|
||||||
files: |
|
files: |
|
||||||
artifacts/linux-builds/*
|
artifacts/arch-package/*.pkg.tar.zst
|
||||||
artifacts/windows-builds/*
|
artifacts/arch-package/*.src.tar.zst
|
||||||
artifacts/macos-builds/*
|
artifacts/arch-package/.SRCINFO
|
||||||
|
artifacts/linux-builds/**/*
|
||||||
|
artifacts/windows-builds/**/*
|
||||||
|
artifacts/macos-builds/**/*
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
draft: false
|
draft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|
||||||
|
|||||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,3 +1,23 @@
|
|||||||
dist/*
|
# General / Node
|
||||||
node_modules/*
|
node_modules/
|
||||||
package-lock.json
|
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
|
||||||
|
|||||||
54
BUILD.md
54
BUILD.md
@@ -1,54 +0,0 @@
|
|||||||
# Build Instructions
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Install dependencies:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
### Build for current platform:
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build for specific platform:
|
|
||||||
|
|
||||||
**Windows:**
|
|
||||||
```bash
|
|
||||||
npm run build:win
|
|
||||||
```
|
|
||||||
|
|
||||||
**Linux:**
|
|
||||||
```bash
|
|
||||||
npm run build:linux
|
|
||||||
```
|
|
||||||
|
|
||||||
**macOS:**
|
|
||||||
```bash
|
|
||||||
npm run build:mac
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build for all platforms:
|
|
||||||
```bash
|
|
||||||
npm run build:all
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Built executables will be in the `dist/` directory:
|
|
||||||
|
|
||||||
- **Windows**: `Hytale F2P Launcher Setup.exe` (NSIS installer) and `Hytale F2P Launcher.exe` (portable)
|
|
||||||
- **Linux**: `Hytale F2P Launcher.AppImage` and `Hytale F2P Launcher.deb`
|
|
||||||
- **macOS**: `Hytale F2P Launcher.dmg` and `Hytale F2P Launcher.zip`
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Icons need to be placed in `build/` directory:
|
|
||||||
- `icon.ico` for Windows
|
|
||||||
- `icon.png` for Linux
|
|
||||||
- `icon.icns` for macOS
|
|
||||||
- To build for macOS on non-Mac systems, you'll need to run it on a Mac or use a CI/CD service
|
|
||||||
|
|
||||||
735
GUI/index.html
735
GUI/index.html
@@ -1,70 +1,98 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Hytale F2P Launcher</title>
|
<title>Hytale F2P Launcher</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link
|
||||||
<link rel="stylesheet" href="style.css">
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-black text-white overflow-hidden font-sans select-none" tabindex="-1">
|
<body class="bg-black text-white overflow-hidden font-sans select-none" tabindex="-1">
|
||||||
<div class="absolute inset-0 z-0">
|
<div class="absolute inset-0 z-0">
|
||||||
<img src="https://i.imgur.com/Visrk66.png" alt="Background" class="w-full h-full object-cover" />
|
<img src="https://assets.authbp.xyz/bg.png" alt="Background" class="w-full h-full object-cover" />
|
||||||
<div class="absolute inset-0 bg-black/60"></div>
|
<div class="absolute inset-0 bg-black/60"></div>
|
||||||
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"%3E%3Cfilter id="noiseFilter"%3E%3CfeTurbulence type="fractalNoise" baseFrequency="0.65" numOctaves="3" stitchTiles="stitch"/%3E%3C/filter%3E%3Crect width="100%25" height="100%25" filter="url(%23noiseFilter)" opacity="0.1"/%3E%3C/svg%3E')] opacity-20"></div>
|
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg viewBox=" 0 0 256 256"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" %3E%3Cfilter id="noiseFilter" %3E%3CfeTurbulence type="fractalNoise"
|
||||||
|
baseFrequency="0.65" numOctaves="3" stitchTiles="stitch" /%3E%3C/filter%3E%3Crect width="100%25"
|
||||||
|
height="100%25" filter="url(%23noiseFilter)" opacity="0.1" /%3E%3C/svg%3E')] opacity-20"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex w-full h-screen relative z-10">
|
<div class="flex w-full h-screen relative z-10">
|
||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
<div class="sidebar-logo">
|
<div class="sidebar-logo">
|
||||||
<img src="./icon.png" alt="Hytale Logo" />
|
<img src="./icon.png" alt="Hytale Logo" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-nav">
|
<div class="sidebar-nav">
|
||||||
<div class="nav-item active" data-page="play">
|
<div class="nav-item active" data-page="play">
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
<span class="nav-tooltip">Play</span>
|
<span class="nav-tooltip" data-i18n="nav.play">Play</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" data-page="mods">
|
<div class="nav-item" data-page="mods">
|
||||||
<i class="fas fa-box"></i>
|
<i class="fas fa-box"></i>
|
||||||
<span class="nav-tooltip">Mods</span>
|
<span class="nav-tooltip" data-i18n="nav.mods">Mods</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" data-page="news">
|
<div class="nav-item" data-page="news">
|
||||||
<i class="fas fa-newspaper"></i>
|
<i class="fas fa-newspaper"></i>
|
||||||
<span class="nav-tooltip">News</span>
|
<span class="nav-tooltip" data-i18n="nav.news">News</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" data-page="chat">
|
<div class="nav-item" data-page="chat">
|
||||||
<i class="fas fa-comments"></i>
|
<i class="fas fa-comments"></i>
|
||||||
<span class="nav-tooltip">Players Chat</span>
|
<span class="nav-tooltip" data-i18n="nav.chat">Players Chat</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" data-page="settings">
|
<div class="nav-item" data-page="settings">
|
||||||
<i class="fas fa-cog"></i>
|
<i class="fas fa-cog"></i>
|
||||||
<span class="nav-tooltip">Settings</span>
|
<span class="nav-tooltip" data-i18n="nav.settings">Settings</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" data-page="skins">
|
<div class="nav-item logs-nav-item" data-page="logs" id="openLogsBtn" onclick="openLogs()">
|
||||||
<i class="fas fa-user"></i>
|
<i class="fas fa-terminal"></i>
|
||||||
<span class="nav-tooltip">Skins</span>
|
<span class="nav-tooltip">Logs</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div id="playersOnlineCounter" class="players-counter">
|
<div id="playersOnlineCounter" class="players-counter">
|
||||||
<i class="fas fa-users"></i>
|
<i class="fas fa-users"></i>
|
||||||
<span class="counter-label">Players:</span>
|
<span class="counter-label" data-i18n="header.playersLabel">Players:</span>
|
||||||
<span id="onlineCount" class="counter-value">0</span>
|
<span id="onlineCount" class="counter-value">0</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-selector" id="profileSelector">
|
||||||
|
<button class="profile-btn" onclick="toggleProfileDropdown()">
|
||||||
|
<i class="fas fa-user-circle"></i>
|
||||||
|
<span id="currentProfileName">Default</span>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
<div class="profile-dropdown" id="profileDropdown">
|
||||||
|
<div class="profile-list" id="profileList">
|
||||||
|
<!-- Profiles populated by JS -->
|
||||||
|
</div>
|
||||||
|
<div class="profile-divider"></div>
|
||||||
|
<div class="profile-action" onclick="openProfileManager()">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
<span data-i18n="header.manageProfiles">Manage Profiles</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="window-controls">
|
<div class="window-controls">
|
||||||
<button class="control-btn minimize" onclick="window.electronAPI?.minimizeWindow()">
|
<button class="control-btn minimize" onclick="window.electronAPI?.minimizeWindow()">
|
||||||
<i class="fas fa-minus"></i>
|
<i class="fas fa-minus"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="control-btn maximize" onclick="toggleMaximize()">
|
||||||
|
<i class="fas fa-square"></i>
|
||||||
|
</button>
|
||||||
<button class="control-btn close" onclick="window.electronAPI?.closeWindow()">
|
<button class="control-btn close" onclick="window.electronAPI?.closeWindow()">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -75,9 +103,6 @@
|
|||||||
<h1 class="game-title">
|
<h1 class="game-title">
|
||||||
HY<span class="title-accent">TALE</span>
|
HY<span class="title-accent">TALE</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="game-tags">
|
|
||||||
<span class="tag">FREE TO PLAY</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-pages">
|
<div class="content-pages">
|
||||||
@@ -85,28 +110,57 @@
|
|||||||
<div class="install-content">
|
<div class="install-content">
|
||||||
<div class="install-header">
|
<div class="install-header">
|
||||||
<h1 class="install-title">
|
<h1 class="install-title">
|
||||||
HYTA<span class="title-accent">LE</span>
|
HY<span class="title-accent">TALE</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="install-subtitle">FREE TO PLAY LAUNCHER</p>
|
<p class="install-subtitle" data-i18n="install.title">FREE TO PLAY LAUNCHER</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="install-form">
|
<div class="install-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Player Name</label>
|
<label class="form-label" data-i18n="install.playerName">Player Name</label>
|
||||||
<input type="text" id="installPlayerName" placeholder="Enter your name" class="form-input" value="Player" />
|
<input type="text" id="installPlayerName"
|
||||||
|
data-i18n-placeholder="install.playerNamePlaceholder" class="form-input"
|
||||||
|
value="Player" />
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label class="checkbox-group">
|
<label class="checkbox-group">
|
||||||
<input type="checkbox" id="installCustomCheck" class="custom-checkbox">
|
<input type="checkbox" id="installCustomCheck" class="custom-checkbox">
|
||||||
<span class="checkbox-label">Custom Installation</span>
|
<span class="checkbox-label" data-i18n="install.customInstallation">Custom
|
||||||
|
Installation</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div id="installCustomOptions" class="custom-options">
|
<div id="installCustomOptions" class="custom-options">
|
||||||
<div class="form-subgroup">
|
<div class="form-subgroup">
|
||||||
<label class="form-label">Installation Folder</label>
|
<label class="form-label" data-i18n="install.installationFolder">Installation
|
||||||
|
Folder</label>
|
||||||
<div class="input-with-button">
|
<div class="input-with-button">
|
||||||
<input type="text" id="installPath" placeholder="Default location" class="form-input" readonly />
|
<input type="text" id="installPath"
|
||||||
|
data-i18n-placeholder="install.pathPlaceholder" class="form-input"
|
||||||
|
readonly />
|
||||||
<button onclick="browseInstallPath()" class="browse-btn">
|
<button onclick="browseInstallPath()" class="browse-btn">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -114,10 +168,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="installBtn" class="install-button" onclick="installGame()">
|
<button id="installBtn" class="install-button" onclick="installGame()">
|
||||||
<i class="fas fa-download mr-2"></i>
|
<i class="fas fa-download mr-2"></i>
|
||||||
<span id="installText">INSTALL HYTALE</span>
|
<span id="installText" data-i18n="install.installButton">INSTALL HYTALE</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,14 +184,15 @@
|
|||||||
<div class="play-header">
|
<div class="play-header">
|
||||||
<h2 class="play-title">
|
<h2 class="play-title">
|
||||||
<i class="fas fa-play-circle mr-2"></i>
|
<i class="fas fa-play-circle mr-2"></i>
|
||||||
READY TO PLAY
|
<span data-i18n="play.ready">READY TO PLAY</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="play-subtitle">Launch Hytale and enter the adventure</p>
|
<p class="play-subtitle" data-i18n="play.subtitle">Launch Hytale and enter the
|
||||||
|
adventure</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="homePlayBtn" class="home-play-button" onclick="launch()">
|
<button id="homePlayBtn" class="home-play-button" onclick="launch()">
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
<span>PLAY HYTALE</span>
|
<span data-i18n="play.playButton">PLAY HYTALE</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,10 +201,11 @@
|
|||||||
<div class="news-header">
|
<div class="news-header">
|
||||||
<h2 class="news-title">
|
<h2 class="news-title">
|
||||||
<i class="fas fa-star mr-2"></i>
|
<i class="fas fa-star mr-2"></i>
|
||||||
LATEST NEWS
|
<span data-i18n="play.latestNews">LATEST NEWS</span>
|
||||||
</h2>
|
</h2>
|
||||||
<button class="view-all-btn" onclick="navigateToPage('news')">
|
<button class="view-all-btn" onclick="navigateToPage('news')">
|
||||||
VIEW ALL <i class="fas fa-arrow-right ml-1"></i>
|
<span data-i18n="play.viewAll">VIEW ALL</span> <i
|
||||||
|
class="fas fa-arrow-right ml-1"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="newsGrid" class="news-grid-horizontal"></div>
|
<div id="newsGrid" class="news-grid-horizontal"></div>
|
||||||
@@ -160,12 +216,13 @@
|
|||||||
<div class="mods-header">
|
<div class="mods-header">
|
||||||
<div class="mods-search-container">
|
<div class="mods-search-container">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
<input type="text" id="modsSearch" placeholder="Search mods..." class="mods-search" />
|
<input type="text" id="modsSearch" data-i18n-placeholder="mods.searchPlaceholder"
|
||||||
|
class="mods-search" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mods-actions">
|
<div class="mods-actions">
|
||||||
<button id="myModsBtn" class="mods-btn-primary">
|
<button id="myModsBtn" class="mods-btn-primary">
|
||||||
<i class="fas fa-box"></i>
|
<i class="fas fa-box"></i>
|
||||||
MY MODS
|
<span data-i18n="mods.myMods">MY MODS</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,13 +233,14 @@
|
|||||||
<div class="mods-pagination">
|
<div class="mods-pagination">
|
||||||
<button id="prevPage" class="pagination-btn">
|
<button id="prevPage" class="pagination-btn">
|
||||||
<i class="fas fa-chevron-left"></i>
|
<i class="fas fa-chevron-left"></i>
|
||||||
PREVIOUS
|
<span data-i18n="mods.previous">PREVIOUS</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="pagination-info">
|
<span class="pagination-info">
|
||||||
Page <span id="currentPage">1</span> of <span id="totalPages">1</span>
|
<span data-i18n="mods.page">Page</span> <span id="currentPage">1</span> <span
|
||||||
|
data-i18n="mods.of">of</span> <span id="totalPages">1</span>
|
||||||
</span>
|
</span>
|
||||||
<button id="nextPage" class="pagination-btn">
|
<button id="nextPage" class="pagination-btn">
|
||||||
NEXT
|
<span data-i18n="mods.next">NEXT</span>
|
||||||
<i class="fas fa-chevron-right"></i>
|
<i class="fas fa-chevron-right"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,7 +250,7 @@
|
|||||||
<div class="news-header">
|
<div class="news-header">
|
||||||
<h2 class="news-title">
|
<h2 class="news-title">
|
||||||
<i class="fas fa-newspaper mr-2"></i>
|
<i class="fas fa-newspaper mr-2"></i>
|
||||||
ALL NEWS
|
<span data-i18n="news.title">ALL NEWS</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div id="allNewsGrid" class="news-grid-full"></div>
|
<div id="allNewsGrid" class="news-grid-full"></div>
|
||||||
@@ -203,28 +261,30 @@
|
|||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<h2 class="chat-title">
|
<h2 class="chat-title">
|
||||||
<i class="fas fa-comments mr-2"></i>
|
<i class="fas fa-comments mr-2"></i>
|
||||||
PLAYERS CHAT
|
<span data-i18n="chat.title">PLAYERS CHAT</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="chat-online-badge">
|
<div class="chat-header-actions">
|
||||||
<i class="fas fa-circle"></i>
|
<button id="chatColorBtn" class="chat-color-btn">
|
||||||
<span id="chatOnlineCount">0</span> online
|
<i class="fas fa-palette"></i>
|
||||||
|
<span data-i18n="chat.pickColor">Color</span>
|
||||||
|
</button>
|
||||||
|
<div class="chat-online-badge">
|
||||||
|
<i class="fas fa-circle"></i>
|
||||||
|
<span id="chatOnlineCount">0</span> <span data-i18n="chat.online">online</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-body">
|
<div class="chat-body">
|
||||||
<div id="chatMessages" class="chat-messages">
|
<div id="chatMessages" class="chat-messages">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-footer">
|
<div class="chat-footer">
|
||||||
<div class="chat-input-container">
|
<div class="chat-input-container">
|
||||||
<textarea
|
<textarea id="chatInput" class="chat-input"
|
||||||
id="chatInput"
|
data-i18n-placeholder="chat.inputPlaceholder" rows="1"
|
||||||
class="chat-input"
|
maxlength="500"></textarea>
|
||||||
placeholder="Type your message... (Links are automatically censored)"
|
|
||||||
rows="1"
|
|
||||||
maxlength="500"
|
|
||||||
></textarea>
|
|
||||||
<button id="chatSendBtn" class="chat-send-btn">
|
<button id="chatSendBtn" class="chat-send-btn">
|
||||||
<i class="fas fa-paper-plane"></i>
|
<i class="fas fa-paper-plane"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -233,7 +293,7 @@
|
|||||||
<span class="chat-char-counter" id="chatCharCounter">0/500</span>
|
<span class="chat-char-counter" id="chatCharCounter">0/500</span>
|
||||||
<span class="chat-warning-text">
|
<span class="chat-warning-text">
|
||||||
<i class="fas fa-shield-alt"></i>
|
<i class="fas fa-shield-alt"></i>
|
||||||
Secure chat - Links are censored
|
<span data-i18n="chat.secureChat">Secure chat - Links are censored</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,96 +305,302 @@
|
|||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h2 class="settings-title">
|
<h2 class="settings-title">
|
||||||
<i class="fas fa-cog mr-2"></i>
|
<i class="fas fa-cog mr-2"></i>
|
||||||
SETTINGS
|
<span data-i18n="settings.title">SETTINGS</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<div class="settings-section">
|
|
||||||
<h3 class="settings-section-title">
|
|
||||||
<i class="fas fa-coffee"></i>
|
|
||||||
Java Runtime
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="settings-option">
|
|
||||||
<label class="settings-checkbox">
|
|
||||||
<input type="checkbox" id="customJavaCheck" />
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
<div class="checkbox-content">
|
|
||||||
<div class="checkbox-title">Use Custom Java Path</div>
|
|
||||||
<div class="checkbox-description">Override the bundled Java runtime with your own installation</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="customJavaOptions" class="custom-java-options" style="display: none;">
|
|
||||||
<div class="settings-input-group">
|
|
||||||
<label class="settings-input-label">Java Executable Path</label>
|
|
||||||
<div class="settings-input-with-button">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="customJavaPath"
|
|
||||||
class="settings-input"
|
|
||||||
placeholder="Select Java path..."
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
<button id="browseJavaBtn" class="settings-browse-btn">
|
|
||||||
<i class="fas fa-folder-open"></i>
|
|
||||||
Browse
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="settings-hint">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
Select the Java installation folder (supports Windows, Mac, Linux)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3 class="settings-section-title">
|
<h3 class="settings-section-title">
|
||||||
<i class="fas fa-gamepad"></i>
|
<i class="fas fa-gamepad"></i>
|
||||||
Game Options
|
<span data-i18n="settings.game">Game Options</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="settings-option">
|
<div class="settings-option">
|
||||||
<div class="settings-input-group">
|
<div class="settings-input-group">
|
||||||
<label class="settings-input-label">Player Name</label>
|
<label class="settings-input-label" data-i18n="settings.playerName">Player
|
||||||
<input
|
Name</label>
|
||||||
type="text"
|
<input type="text" id="settingsPlayerName" class="settings-input"
|
||||||
id="settingsPlayerName"
|
data-i18n-placeholder="settings.playerNamePlaceholder" maxlength="16" />
|
||||||
class="settings-input"
|
|
||||||
placeholder="Enter your player name"
|
|
||||||
maxlength="16"
|
|
||||||
/>
|
|
||||||
<p class="settings-hint">
|
<p class="settings-hint">
|
||||||
<i class="fas fa-user"></i>
|
<i class="fas fa-user"></i>
|
||||||
This name will be used in-game (1-16 characters)
|
<span data-i18n="settings.playerNameHint">This name will be used in-game
|
||||||
|
(1-16 characters)</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-option">
|
<div class="settings-option">
|
||||||
<div class="settings-button-group">
|
<div class="settings-button-group">
|
||||||
<button id="openGameLocationBtn" class="settings-action-btn" onclick="openGameLocation()">
|
<button id="openGameLocationBtn" class="settings-action-btn"
|
||||||
|
onclick="openGameLocation()">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open"></i>
|
||||||
<div class="btn-content">
|
<div class="btn-content">
|
||||||
<div class="btn-title">Open Game Location</div>
|
<div class="btn-title" data-i18n="settings.openGameLocation">Open
|
||||||
<div class="btn-description">Open the game installation folder</div>
|
Game Location</div>
|
||||||
|
<div class="btn-description"
|
||||||
|
data-i18n="settings.openGameLocationDesc">Open the game
|
||||||
|
installation folder</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-option">
|
||||||
|
<div class="settings-button-group">
|
||||||
|
<button id="repairGameBtn" class="settings-action-btn"
|
||||||
|
onclick="repairGame()">
|
||||||
|
<i class="fas fa-tools"></i>
|
||||||
|
<div class="btn-content">
|
||||||
|
<div class="btn-title" data-i18n="settings.repairGame">Repair Game
|
||||||
|
</div>
|
||||||
|
<div class="btn-description" data-i18n="settings.reinstallGame">
|
||||||
|
Reinstall game files (preserves data)
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<label for="gpu-auto" data-i18n="settings.gpuAuto">Auto</label>
|
||||||
|
<input type="radio" id="gpu-integrated" name="gpuPreference"
|
||||||
|
value="integrated">
|
||||||
|
<label for="gpu-integrated"
|
||||||
|
data-i18n="settings.gpuIntegrated">Integrated</label>
|
||||||
|
<input type="radio" id="gpu-dedicated" name="gpuPreference"
|
||||||
|
value="dedicated">
|
||||||
|
<label for="gpu-dedicated"
|
||||||
|
data-i18n="settings.gpuDedicated">Dedicated</label>
|
||||||
|
</div>
|
||||||
|
<p class="settings-hint">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span data-i18n="settings.gpuHint">Select your preferred GPU (Linux:
|
||||||
|
affects DRI_PRIME)</span>
|
||||||
|
</p>
|
||||||
|
<div id="gpu-detection-info" class="gpu-detection-info"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3 class="settings-section-title">
|
||||||
|
<i class="fas fa-fingerprint"></i>
|
||||||
|
<span data-i18n="settings.account">Player UUID Management</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="settings-option">
|
||||||
|
<div class="settings-input-group">
|
||||||
|
<label class="settings-input-label" data-i18n="settings.currentUUID">Current
|
||||||
|
UUID</label>
|
||||||
|
<div class="uuid-display-container">
|
||||||
|
<input type="text" id="currentUuid" class="settings-input uuid-input"
|
||||||
|
readonly data-i18n-placeholder="settings.uuidPlaceholder" />
|
||||||
|
<button id="copyUuidBtn" class="uuid-btn copy-btn" title="Copy UUID">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
<button id="regenerateUuidBtn" class="uuid-btn regenerate-btn"
|
||||||
|
title="Generate New UUID">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="settings-hint">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span data-i18n="settings.uuidHint">Your unique player identifier for
|
||||||
|
this username</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-option">
|
||||||
|
<div class="settings-button-group">
|
||||||
|
<button id="manageUuidsBtn" class="settings-action-btn">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
<div class="btn-content">
|
||||||
|
<div class="btn-title" data-i18n="settings.manageUUIDs">Manage All
|
||||||
|
UUIDs</div>
|
||||||
|
<div class="btn-description" data-i18n="settings.manageUUIDsDesc">
|
||||||
|
View and manage all player UUIDs</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3 class="settings-section-title">
|
||||||
|
<i class="fab fa-discord"></i>
|
||||||
|
<span data-i18n="settings.discord">Discord Integration</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="settings-option">
|
||||||
|
<label class="settings-checkbox">
|
||||||
|
<input type="checkbox" id="discordRPCCheck" checked />
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
<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
|
||||||
|
on Discord
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3 class="settings-section-title">
|
||||||
|
<i class="fas fa-window-close"></i>
|
||||||
|
<span data-i18n="settings.closeLauncher">Launcher Behavior</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="settings-option">
|
||||||
|
<label class="settings-checkbox">
|
||||||
|
<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">
|
||||||
|
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>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3 class="settings-section-title">
|
||||||
|
<i class="fas fa-coffee"></i>
|
||||||
|
<span data-i18n="settings.java">Java Runtime</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="settings-option">
|
||||||
|
<label class="settings-checkbox">
|
||||||
|
<input type="checkbox" id="customJavaCheck" />
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
<div class="checkbox-content">
|
||||||
|
<div class="checkbox-title" data-i18n="settings.useCustomJava">Use
|
||||||
|
Custom Java Path</div>
|
||||||
|
<div class="checkbox-description" data-i18n="settings.javaDescription">
|
||||||
|
Override the bundled Java runtime with
|
||||||
|
your own installation</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="customJavaOptions" class="custom-java-options" style="display: none;">
|
||||||
|
<div class="settings-input-group">
|
||||||
|
<label class="settings-input-label" data-i18n="settings.javaPath">Java
|
||||||
|
Executable Path</label>
|
||||||
|
<div class="settings-input-with-button">
|
||||||
|
<input type="text" id="customJavaPath" class="settings-input"
|
||||||
|
data-i18n-placeholder="settings.javaPathPlaceholder" readonly />
|
||||||
|
<button id="browseJavaBtn" class="settings-browse-btn">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
<span data-i18n="settings.javaBrowse">Browse</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="settings-hint">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span data-i18n="settings.javaHint">Select the Java installation folder
|
||||||
|
(supports Windows, Mac, Linux)</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3 class="settings-section-title">
|
||||||
|
<i class="fas fa-language"></i>
|
||||||
|
<span data-i18n="settings.language">Language</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="settings-option">
|
||||||
|
<div class="settings-input-group">
|
||||||
|
<label class="settings-input-label" data-i18n="settings.selectLanguage">Select
|
||||||
|
Language</label>
|
||||||
|
<select id="languageSelect" class="settings-input">
|
||||||
|
<!-- Options populated by i18n.js -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="skins-page" class="page">
|
<div id="logs-page" class="page">
|
||||||
<div class="placeholder-content">
|
<div class="logs-container">
|
||||||
<i class="fas fa-user text-6xl mb-4 text-purple-500"></i>
|
<div class="logs-header">
|
||||||
<h2>Skins</h2>
|
<h2 class="logs-title">
|
||||||
<p>Skin customization coming soon...</p>
|
<i class="fas fa-terminal"></i>
|
||||||
|
<span data-i18n="settings.logs">SYSTEM LOGS</span>
|
||||||
|
</h2>
|
||||||
|
<div class="logs-actions">
|
||||||
|
<button class="logs-action-btn" onclick="copyLogs()">
|
||||||
|
<i class="fas fa-copy"></i> <span data-i18n="settings.logsCopy">Copy</span>
|
||||||
|
</button>
|
||||||
|
<button class="logs-action-btn" onclick="refreshLogs()">
|
||||||
|
<i class="fas fa-sync-alt"></i> <span
|
||||||
|
data-i18n="settings.logsRefresh">Refresh</span>
|
||||||
|
</button>
|
||||||
|
<button class="logs-action-btn" onclick="openLogsFolder()">
|
||||||
|
<i class="fas fa-folder-open"></i> <span data-i18n="settings.logsFolder">Open
|
||||||
|
Folder</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="logsTerminal" class="logs-terminal">
|
||||||
|
<div class="text-gray-500 text-center mt-10" data-i18n="settings.logsLoading">Loading
|
||||||
|
logs...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +613,7 @@
|
|||||||
<div class="mods-modal-header">
|
<div class="mods-modal-header">
|
||||||
<h2 class="mods-modal-title">
|
<h2 class="mods-modal-title">
|
||||||
<i class="fas fa-box mr-2"></i>
|
<i class="fas fa-box mr-2"></i>
|
||||||
MY MODS
|
<span data-i18n="mods.modalTitle">MY MODS</span>
|
||||||
</h2>
|
</h2>
|
||||||
<button id="closeMyModsModal" class="mods-modal-close">
|
<button id="closeMyModsModal" class="mods-modal-close">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
@@ -363,7 +629,7 @@
|
|||||||
<div id="progressOverlay" class="progress-overlay" style="display: none;">
|
<div id="progressOverlay" class="progress-overlay" style="display: none;">
|
||||||
<div class="progress-content">
|
<div class="progress-content">
|
||||||
<div class="progress-info">
|
<div class="progress-info">
|
||||||
<span id="progressText">Initializing...</span>
|
<span id="progressText" data-i18n="progress.initializing">Initializing...</span>
|
||||||
<span id="progressPercent">0%</span>
|
<span id="progressPercent">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-bar-container">
|
<div class="progress-bar-container">
|
||||||
@@ -373,6 +639,23 @@
|
|||||||
<span id="progressSpeed"></span>
|
<span id="progressSpeed"></span>
|
||||||
<span id="progressSize"></span>
|
<span id="progressSize"></span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -381,68 +664,226 @@
|
|||||||
<div class="chat-username-modal-header">
|
<div class="chat-username-modal-header">
|
||||||
<h2 class="chat-username-modal-title">
|
<h2 class="chat-username-modal-title">
|
||||||
<i class="fas fa-comments mr-2"></i>
|
<i class="fas fa-comments mr-2"></i>
|
||||||
Join Chat
|
<span data-i18n="chat.joinChat">Join Chat</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-username-modal-body">
|
<div class="chat-username-modal-body">
|
||||||
<p class="chat-username-modal-description">
|
<p class="chat-username-modal-description" data-i18n="chat.chooseUsername">
|
||||||
Choose a username to join the Players Chat
|
Choose a username to join the Players Chat
|
||||||
</p>
|
</p>
|
||||||
<div class="chat-username-input-group">
|
<div class="chat-username-input-group">
|
||||||
<label for="chatUsernameInput" class="chat-username-label">Username</label>
|
<label for="chatUsernameInput" class="chat-username-label"
|
||||||
<input
|
data-i18n="chat.username">Username</label>
|
||||||
type="text"
|
<input type="text" id="chatUsernameInput" class="chat-username-input"
|
||||||
id="chatUsernameInput"
|
data-i18n-placeholder="chat.usernamePlaceholder" maxlength="20" autocomplete="off" />
|
||||||
class="chat-username-input"
|
<span class="chat-username-hint" data-i18n="chat.usernameHint">3-20 characters, letters, numbers, -
|
||||||
placeholder="Enter your username..."
|
and _ only</span>
|
||||||
maxlength="20"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
<span class="chat-username-hint">3-20 characters, letters, numbers, - and _ only</span>
|
|
||||||
<span id="chatUsernameError" class="chat-username-error"></span>
|
<span id="chatUsernameError" class="chat-username-error"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-username-modal-footer">
|
<div class="chat-username-modal-footer">
|
||||||
<button id="chatUsernameCancel" class="chat-username-btn-cancel">
|
<button id="chatUsernameCancel" class="chat-username-btn-cancel">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
Cancel
|
<span data-i18n="common.cancel">Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="chatUsernameSubmit" class="chat-username-btn-submit">
|
<button id="chatUsernameSubmit" class="chat-username-btn-submit">
|
||||||
<i class="fas fa-check"></i>
|
<i class="fas fa-check"></i>
|
||||||
Join Chat
|
<span data-i18n="chat.joinButton">Join Chat</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- UUID Management Modal -->
|
||||||
|
<div id="uuidModal" class="uuid-modal" style="display: none;">
|
||||||
|
<div class="uuid-modal-content">
|
||||||
|
<div class="uuid-modal-header">
|
||||||
|
<h2 class="uuid-modal-title">
|
||||||
|
<i class="fas fa-fingerprint mr-2"></i>
|
||||||
|
<span data-i18n="uuid.modalTitle">UUID Management</span>
|
||||||
|
</h2>
|
||||||
|
<button id="uuidModalClose" class="modal-close-btn">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uuid-modal-body">
|
||||||
|
<div class="uuid-current-section">
|
||||||
|
<h3 class="uuid-section-title" data-i18n="uuid.currentUserUUID">Current User UUID</h3>
|
||||||
|
<div class="uuid-current-display">
|
||||||
|
<input type="text" id="modalCurrentUuid" class="uuid-display-input" readonly />
|
||||||
|
<button id="modalCopyUuidBtn" class="uuid-action-btn copy-btn" title="Copy UUID">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
<button id="modalRegenerateUuidBtn" class="uuid-action-btn regenerate-btn"
|
||||||
|
title="Generate New UUID">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uuid-list-section">
|
||||||
|
<div class="uuid-list-header">
|
||||||
|
<h3 class="uuid-section-title" data-i18n="uuid.allPlayerUUIDs">All Player UUIDs</h3>
|
||||||
|
<button id="generateNewUuidBtn" class="uuid-generate-btn">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
<span data-i18n="uuid.generateNew">Generate New UUID</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="uuidList" class="uuid-list">
|
||||||
|
<div class="uuid-loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
<span data-i18n="uuid.loadingUUIDs">Loading UUIDs...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uuid-custom-section">
|
||||||
|
<h3 class="uuid-section-title" data-i18n="uuid.setCustomUUID">Set Custom UUID</h3>
|
||||||
|
<div class="uuid-custom-form">
|
||||||
|
<input type="text" id="customUuidInput" class="uuid-input"
|
||||||
|
data-i18n-placeholder="uuid.customPlaceholder" maxlength="36" />
|
||||||
|
<button id="setCustomUuidBtn" class="uuid-set-btn">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
<span data-i18n="uuid.setUUID">Set UUID</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="uuid-custom-hint">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span data-i18n="uuid.warning">Warning: Setting a custom UUID will change your current player
|
||||||
|
identity</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Manager Modal -->
|
||||||
|
<div id="profileManagerModal" class="profile-modal" style="display: none;">
|
||||||
|
<div class="profile-modal-content">
|
||||||
|
<div class="profile-modal-header">
|
||||||
|
<h2 class="profile-modal-title">
|
||||||
|
<i class="fas fa-users-cog mr-2"></i>
|
||||||
|
<span data-i18n="profiles.modalTitle">Manage Profiles</span>
|
||||||
|
</h2>
|
||||||
|
<button class="modal-close-btn" onclick="closeProfileManager()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="profile-modal-body">
|
||||||
|
<div class="profile-manager-list" id="managerProfileList">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</div>
|
||||||
|
<div class="profile-create-section">
|
||||||
|
<input type="text" id="newProfileName" data-i18n-placeholder="profiles.newProfilePlaceholder"
|
||||||
|
class="profile-input" maxlength="20">
|
||||||
|
<button class="profile-create-btn" onclick="createNewProfile()">
|
||||||
|
<i class="fas fa-plus"></i> <span data-i18n="profiles.createProfile">Create Profile</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="version-display-bottom">
|
||||||
|
<i class="fas fa-code-branch"></i>
|
||||||
|
<span id="launcherVersion">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="fixed bottom-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-sm px-4 py-2">
|
<footer class="fixed bottom-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-sm px-4 py-2">
|
||||||
<div class="flex items-center justify-center text-xs text-gray-400">
|
<div class="flex items-center justify-center text-xs text-gray-400">
|
||||||
<span>Made by <a href="https://github.com/amiayweb" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">@amiayweb</a></span>
|
<span>Made by <a href="https://github.com/amiayweb" target="_blank"
|
||||||
|
class="text-blue-400 hover:text-blue-300 transition-colors">@amiayweb</a> & <a
|
||||||
|
href="https://github.com/Relyz1993" target="_blank"
|
||||||
|
class="text-blue-400 hover:text-blue-300 transition-colors">@Relyz</a></span>
|
||||||
<span class="mx-2">|</span>
|
<span class="mx-2">|</span>
|
||||||
<span>Contributors:
|
<span>Contributors:
|
||||||
<a href="https://github.com/chasem-dev" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">@chasem-dev</a>,
|
<a href="https://github.com/chasem-dev" target="_blank"
|
||||||
<a href="https://github.com/crimera" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">@crimera</a>,
|
class="text-blue-400 hover:text-blue-300 transition-colors">@chasem-dev</a>,
|
||||||
<a href="https://github.com/sanasol" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">@sanasol</a>,
|
<a href="https://github.com/crimera" target="_blank"
|
||||||
<a href="https://github.com/Terromur" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">@terromur</a>,
|
class="text-blue-400 hover:text-blue-300 transition-colors">@crimera</a>,
|
||||||
<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/sanasol" target="_blank"
|
||||||
|
class="text-blue-400 hover:text-blue-300 transition-colors">@sanasol</a>,
|
||||||
|
<a href="https://github.com/Terromur" target="_blank"
|
||||||
|
class="text-blue-400 hover:text-blue-300 transition-colors">@terromur</a>,
|
||||||
|
<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>,
|
||||||
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script type="module" src="js/script.js"></script> <!-- Discord Notification -->
|
<script type="module" src="js/script.js"></script> <!-- Discord Notification -->
|
||||||
<div id="discordNotification" class="discord-notification">
|
<div id="discordNotification" class="discord-notification">
|
||||||
<div class="notification-content">
|
<div class="notification-content">
|
||||||
<i class="fab fa-discord"></i>
|
<i class="fab fa-discord"></i>
|
||||||
<span class="notification-text">Join our Discord community!</span>
|
<span class="notification-text" data-i18n="discord.notificationText">Join our Discord community!</span>
|
||||||
<button class="notification-action" onclick="window.electronAPI?.openExternal('https://discord.gg/n6HZ7NwSQd')">
|
<button class="notification-action"
|
||||||
Join Discord
|
onclick="window.electronAPI?.openExternal('https://discord.gg/n6HZ7NwSQd')">
|
||||||
|
<span data-i18n="discord.joinButton">Join Discord</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="notification-close" onclick="closeDiscordNotification()">
|
<button class="notification-close" onclick="closeDiscordNotification()">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal pour sélectionner la couleur du chat -->
|
||||||
|
<div id="chatColorModal" class="chat-color-modal" style="display: none;">
|
||||||
|
<div class="chat-color-modal-content">
|
||||||
|
<div class="chat-color-modal-header">
|
||||||
|
<h3 class="chat-color-modal-title">
|
||||||
|
<i class="fas fa-palette"></i>
|
||||||
|
<span data-i18n="chat.colorModal.title">Customize Username Color</span>
|
||||||
|
</h3>
|
||||||
|
<button class="modal-close-btn" onclick="closeChatColorModal()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="chat-color-modal-body">
|
||||||
|
<div id="solidColorSection" class="color-section">
|
||||||
|
<h4 data-i18n="chat.colorModal.chooseSolid">Choose a solid color:</h4>
|
||||||
|
<div class="predefined-colors">
|
||||||
|
<div class="color-option" data-color="#3498db" style="background: #3498db;"></div>
|
||||||
|
<div class="color-option" data-color="#e74c3c" style="background: #e74c3c;"></div>
|
||||||
|
<div class="color-option" data-color="#2ecc71" style="background: #2ecc71;"></div>
|
||||||
|
<div class="color-option" data-color="#f39c12" style="background: #f39c12;"></div>
|
||||||
|
<div class="color-option" data-color="#9b59b6" style="background: #9b59b6;"></div>
|
||||||
|
<div class="color-option" data-color="#1abc9c" style="background: #1abc9c;"></div>
|
||||||
|
<div class="color-option" data-color="#e91e63" style="background: #e91e63;"></div>
|
||||||
|
<div class="color-option" data-color="#ff5722" style="background: #ff5722;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="custom-color-input">
|
||||||
|
<label for="customColor" data-i18n="chat.colorModal.customColor">Custom color:</label>
|
||||||
|
<input type="color" id="customColor" value="#3498db">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-preview">
|
||||||
|
<h4 data-i18n="chat.colorModal.preview">Preview:</h4>
|
||||||
|
<div id="colorPreview" class="preview-username" data-i18n="chat.colorModal.previewUsername">
|
||||||
|
YourUsername</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-color-modal-footer">
|
||||||
|
<button class="btn-secondary" onclick="closeChatColorModal()"><span
|
||||||
|
data-i18n="common.cancel">Cancel</span></button>
|
||||||
|
<button class="btn-primary" onclick="applyChatColor()"><span data-i18n="chat.colorModal.apply">Apply
|
||||||
|
Color</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/i18n.js"></script>
|
||||||
|
<script type="module" src="js/settings.js"></script>
|
||||||
<script type="module" src="js/update.js"></script>
|
<script type="module" src="js/update.js"></script>
|
||||||
|
<script src="js/updater.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
154
GUI/js/chat.js
154
GUI/js/chat.js
@@ -3,13 +3,26 @@ let socket = null;
|
|||||||
let isAuthenticated = false;
|
let isAuthenticated = false;
|
||||||
let messageQueue = [];
|
let messageQueue = [];
|
||||||
let chatUsername = '';
|
let chatUsername = '';
|
||||||
const SOCKET_URL = 'http://3.10.208.30:3001';
|
let userColor = '#3498db';
|
||||||
|
let userBadge = null;
|
||||||
|
const SOCKET_URL = 'https://chat.hytalef2p.com';
|
||||||
const MAX_MESSAGE_LENGTH = 500;
|
const MAX_MESSAGE_LENGTH = 500;
|
||||||
|
|
||||||
|
async function getOrCreatePlayerId() {
|
||||||
|
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function initChat() {
|
export async function initChat() {
|
||||||
if (window.electronAPI?.loadChatUsername) {
|
if (window.electronAPI?.loadChatUsername) {
|
||||||
chatUsername = await window.electronAPI.loadChatUsername();
|
chatUsername = await window.electronAPI.loadChatUsername();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.electronAPI?.loadChatColor) {
|
||||||
|
const savedColor = await window.electronAPI.loadChatColor();
|
||||||
|
if (savedColor) {
|
||||||
|
userColor = savedColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!chatUsername || chatUsername.trim() === '') {
|
if (!chatUsername || chatUsername.trim() === '') {
|
||||||
showUsernameModal();
|
showUsernameModal();
|
||||||
@@ -17,6 +30,7 @@ export async function initChat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupChatUI();
|
setupChatUI();
|
||||||
|
setupColorSelector();
|
||||||
await connectToChat();
|
await connectToChat();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,13 +150,22 @@ async function connectToChat() {
|
|||||||
reconnectionDelay: 1000
|
reconnectionDelay: 1000
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('connect', () => {
|
socket.on('connect', async () => {
|
||||||
console.log('Connected to chat server');
|
console.log('Connected to chat server');
|
||||||
socket.emit('authenticate', { username: chatUsername, userId });
|
|
||||||
|
const uuid = await window.electronAPI?.getCurrentUuid();
|
||||||
|
|
||||||
|
socket.emit('authenticate', {
|
||||||
|
username: chatUsername,
|
||||||
|
userId,
|
||||||
|
uuid: uuid,
|
||||||
|
userColor: userColor
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('authenticated', (data) => {
|
socket.on('authenticated', (data) => {
|
||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
|
userBadge = data.badge;
|
||||||
addSystemMessage(`Connected as ${data.username}`);
|
addSystemMessage(`Connected as ${data.username}`);
|
||||||
|
|
||||||
while (messageQueue.length > 0) {
|
while (messageQueue.length > 0) {
|
||||||
@@ -155,7 +178,7 @@ async function connectToChat() {
|
|||||||
if (data.type === 'system') {
|
if (data.type === 'system') {
|
||||||
addSystemMessage(data.message);
|
addSystemMessage(data.message);
|
||||||
} else if (data.type === 'user') {
|
} else if (data.type === 'user') {
|
||||||
addUserMessage(data.username, data.message, data.timestamp);
|
addUserMessage(data.username, data.message, data.timestamp, data.userColor, data.badge);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,7 +249,7 @@ function sendMessage() {
|
|||||||
updateCharCounter();
|
updateCharCounter();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addUserMessage(username, message, timestamp) {
|
function addUserMessage(username, message, timestamp, userColor = '#3498db', badge = null) {
|
||||||
const chatMessages = document.getElementById('chatMessages');
|
const chatMessages = document.getElementById('chatMessages');
|
||||||
if (!chatMessages) return;
|
if (!chatMessages) return;
|
||||||
|
|
||||||
@@ -238,14 +261,35 @@ function addUserMessage(username, message, timestamp) {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let badgeHTML = '';
|
||||||
|
if (badge) {
|
||||||
|
let badgeStyle = '';
|
||||||
|
if (badge.style === 'rainbow') {
|
||||||
|
badgeStyle = `background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4, #ffeaa7, #fab1a0, #fd79a8); background-size: 400% 400%; animation: rainbow 3s ease infinite; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; font-weight: bold; display: inline;`;
|
||||||
|
} else if (badge.style === 'gradient') {
|
||||||
|
if (badge.badge === 'CONTRIBUTOR') {
|
||||||
|
badgeStyle = `background: linear-gradient(45deg, #22c55e, #16a34a); background-size: 200% 200%; animation: contributorGlow 2s ease infinite; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; font-weight: bold; display: inline;`;
|
||||||
|
} else {
|
||||||
|
badgeStyle = `color: ${badge.color}; font-weight: bold; display: inline;`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeHTML = `<span class="user-badge" style="${badgeStyle}">[${badge.badge}]</span> `;
|
||||||
|
}
|
||||||
|
|
||||||
messageDiv.innerHTML = `
|
messageDiv.innerHTML = `
|
||||||
<div class="message-header">
|
<div class="message-header">
|
||||||
<span class="message-username">${escapeHtml(username)}</span>
|
<span class="message-user-info">${badgeHTML}<span class="message-username" style="font-weight: bold;" data-username-color="${userColor}">${escapeHtml(username)}</span></span>
|
||||||
<span class="message-time">${time}</span>
|
<span class="message-time">${time}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-content">${message}</div>
|
<div class="message-content">${message}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const usernameElement = messageDiv.querySelector('.message-username');
|
||||||
|
if (usernameElement) {
|
||||||
|
applyUserColorStyle(usernameElement, userColor);
|
||||||
|
}
|
||||||
|
|
||||||
chatMessages.appendChild(messageDiv);
|
chatMessages.appendChild(messageDiv);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
@@ -352,6 +396,104 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function setupColorSelector() {
|
||||||
|
const colorBtn = document.getElementById('chatColorBtn');
|
||||||
|
if (colorBtn) {
|
||||||
|
colorBtn.addEventListener('click', showChatColorModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorOptions = document.querySelectorAll('.color-option');
|
||||||
|
colorOptions.forEach(option => {
|
||||||
|
option.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected'));
|
||||||
|
option.classList.add('selected');
|
||||||
|
updateColorPreview();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const customColor = document.getElementById('customColor');
|
||||||
|
if (customColor) {
|
||||||
|
customColor.addEventListener('input', () => {
|
||||||
|
document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected'));
|
||||||
|
updateColorPreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showChatColorModal() {
|
||||||
|
const modal = document.getElementById('chatColorModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
updateColorPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.closeChatColorModal = function() {
|
||||||
|
const modal = document.getElementById('chatColorModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColorPreview() {
|
||||||
|
const preview = document.getElementById('colorPreview');
|
||||||
|
if (!preview) return;
|
||||||
|
|
||||||
|
const selectedOption = document.querySelector('.color-option.selected');
|
||||||
|
let color = '#3498db';
|
||||||
|
|
||||||
|
if (selectedOption) {
|
||||||
|
color = selectedOption.dataset.color;
|
||||||
|
} else {
|
||||||
|
const customColor = document.getElementById('customColor');
|
||||||
|
if (customColor) color = customColor.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.style.color = color;
|
||||||
|
preview.style.background = 'transparent';
|
||||||
|
preview.style.webkitBackgroundClip = 'initial';
|
||||||
|
preview.style.webkitTextFillColor = 'initial';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.applyChatColor = async function() {
|
||||||
|
let newColor;
|
||||||
|
|
||||||
|
const selectedOption = document.querySelector('.color-option.selected');
|
||||||
|
if (selectedOption) {
|
||||||
|
newColor = selectedOption.dataset.color;
|
||||||
|
} else {
|
||||||
|
const customColor = document.getElementById('customColor');
|
||||||
|
newColor = customColor ? customColor.value : '#3498db';
|
||||||
|
}
|
||||||
|
|
||||||
|
userColor = newColor;
|
||||||
|
|
||||||
|
if (window.electronAPI?.saveChatColor) {
|
||||||
|
await window.electronAPI.saveChatColor(newColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socket && isAuthenticated) {
|
||||||
|
const uuid = await window.electronAPI?.getCurrentUuid();
|
||||||
|
socket.emit('authenticate', {
|
||||||
|
username: chatUsername,
|
||||||
|
userId: await getOrCreatePlayerId(),
|
||||||
|
uuid: uuid,
|
||||||
|
userColor: userColor
|
||||||
|
});
|
||||||
|
|
||||||
|
addSystemMessage('Username color updated successfully', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
closeChatColorModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUserColorStyle(element, color) {
|
||||||
|
element.style.color = color;
|
||||||
|
element.style.background = 'transparent';
|
||||||
|
element.style.webkitBackgroundClip = 'initial';
|
||||||
|
element.style.webkitTextFillColor = 'initial';
|
||||||
|
}
|
||||||
|
|
||||||
window.ChatAPI = {
|
window.ChatAPI = {
|
||||||
send: sendMessage,
|
send: sendMessage,
|
||||||
disconnect: () => socket?.disconnect()
|
disconnect: () => socket?.disconnect()
|
||||||
|
|||||||
94
GUI/js/i18n.js
Normal file
94
GUI/js/i18n.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Minimal i18n system - optimized async loading
|
||||||
|
const i18n = (() => {
|
||||||
|
let currentLang = 'en';
|
||||||
|
let translations = {};
|
||||||
|
const availableLanguages = [
|
||||||
|
{ code: 'en', name: 'English' },
|
||||||
|
{ code: 'fr', name: 'Français' },
|
||||||
|
{ code: 'de', name: 'Deutsch' },
|
||||||
|
{ code: 'sv', name: 'Svenska' },
|
||||||
|
{ code: 'es-ES', name: 'Español (España)' },
|
||||||
|
{ code: 'pt-BR', name: 'Portuguese (Brazil)' },
|
||||||
|
{ code: 'tr-TR', name: 'Turkish (Turkey)' },
|
||||||
|
{ code: 'pl-PL', name: 'Polish (Poland)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Load single language file
|
||||||
|
async function loadLanguage(lang) {
|
||||||
|
if (translations[lang]) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`locales/${lang}.json`);
|
||||||
|
if (response.ok) {
|
||||||
|
translations[lang] = await response.json();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to load language: ${lang}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get translation by key
|
||||||
|
function t(key) {
|
||||||
|
const keys = key.split('.');
|
||||||
|
let value = translations[currentLang];
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
if (value && value[k] !== undefined) {
|
||||||
|
value = value[k];
|
||||||
|
} else {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set language
|
||||||
|
async function setLanguage(lang) {
|
||||||
|
await loadLanguage(lang);
|
||||||
|
if (translations[lang]) {
|
||||||
|
currentLang = lang;
|
||||||
|
updateDOM();
|
||||||
|
window.electronAPI?.saveLanguage(lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all elements with data-i18n attribute
|
||||||
|
function updateDOM() {
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n');
|
||||||
|
el.textContent = t(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n-placeholder');
|
||||||
|
el.placeholder = t(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n-title');
|
||||||
|
el.title = t(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize - load saved language only
|
||||||
|
async function init(savedLang) {
|
||||||
|
const lang = savedLang || 'en';
|
||||||
|
await loadLanguage(lang);
|
||||||
|
currentLang = lang;
|
||||||
|
updateDOM();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
t,
|
||||||
|
setLanguage,
|
||||||
|
getAvailableLanguages: () => availableLanguages,
|
||||||
|
getCurrentLanguage: () => currentLang
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make i18n globally available
|
||||||
|
window.i18n = i18n;
|
||||||
@@ -33,21 +33,13 @@ export function setupInstallation() {
|
|||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.onProgressUpdate) {
|
if (window.electronAPI && window.electronAPI.onProgressUpdate) {
|
||||||
window.electronAPI.onProgressUpdate((data) => {
|
window.electronAPI.onProgressUpdate((data) => {
|
||||||
|
if (!isDownloading) return;
|
||||||
if (window.LauncherUI) {
|
if (window.LauncherUI) {
|
||||||
window.LauncherUI.showProgress();
|
|
||||||
window.LauncherUI.updateProgress(data);
|
window.LauncherUI.updateProgress(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.onProgressComplete) {
|
|
||||||
window.electronAPI.onProgressComplete(() => {
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.hideProgress();
|
|
||||||
}
|
|
||||||
resetInstallButton();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installGame() {
|
export async function installGame() {
|
||||||
@@ -56,25 +48,33 @@ export async function installGame() {
|
|||||||
const playerName = (installPlayerName ? installPlayerName.value.trim() : '') || 'Player';
|
const playerName = (installPlayerName ? installPlayerName.value.trim() : '') || 'Player';
|
||||||
const installPath = installPathInput ? installPathInput.value.trim() : '';
|
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();
|
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||||
isDownloading = true;
|
isDownloading = true;
|
||||||
|
lockInstallForm();
|
||||||
if (installBtn) {
|
if (installBtn) {
|
||||||
installBtn.disabled = true;
|
installBtn.disabled = true;
|
||||||
installText.textContent = 'INSTALLING...';
|
installText.textContent = window.i18n ? window.i18n.t('install.installing') : 'INSTALLING...';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (window.electronAPI && window.electronAPI.installGame) {
|
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) {
|
if (result.success) {
|
||||||
|
const successMsg = window.i18n ? window.i18n.t('progress.installationComplete') : 'Installation completed successfully!';
|
||||||
if (window.LauncherUI) {
|
if (window.LauncherUI) {
|
||||||
window.LauncherUI.updateProgress({ message: 'Installation completed successfully!' });
|
window.LauncherUI.updateProgress({ message: successMsg });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.LauncherUI.hideProgress();
|
window.LauncherUI.hideProgress();
|
||||||
window.LauncherUI.showLauncherOrInstall(true);
|
window.LauncherUI.showLauncherOrInstall(true);
|
||||||
const playerNameInput = document.getElementById('playerName');
|
const playerNameInput = document.getElementById('playerName');
|
||||||
if (playerNameInput) playerNameInput.value = playerName;
|
if (playerNameInput) playerNameInput.value = playerName;
|
||||||
|
resetInstallButton();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -84,12 +84,15 @@ export async function installGame() {
|
|||||||
simulateInstallation(playerName);
|
simulateInstallation(playerName);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMsg = window.i18n ? window.i18n.t('progress.installationFailed').replace('{error}', error.message) : `Installation failed: ${error.message}`;
|
||||||
|
|
||||||
|
// Reset button state and unlock form on error
|
||||||
|
resetInstallButton();
|
||||||
|
|
||||||
if (window.LauncherUI) {
|
if (window.LauncherUI) {
|
||||||
window.LauncherUI.updateProgress({ message: `Installation failed: ${error.message}` });
|
window.LauncherUI.updateProgress({ message: errorMsg });
|
||||||
setTimeout(() => {
|
// Don't hide progress bar, just update the message
|
||||||
window.LauncherUI.hideProgress();
|
// User can see the error and close it manually
|
||||||
resetInstallButton();
|
|
||||||
}, 3000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,10 +103,13 @@ function simulateInstallation(playerName) {
|
|||||||
progress += Math.random() * 3;
|
progress += Math.random() * 3;
|
||||||
if (progress > 100) progress = 100;
|
if (progress > 100) progress = 100;
|
||||||
|
|
||||||
|
const installingMsg = window.i18n ? window.i18n.t('progress.installingGameFiles') : 'Installing game files...';
|
||||||
|
const completeMsg = window.i18n ? window.i18n.t('progress.installComplete') : 'Installation complete!';
|
||||||
|
|
||||||
if (window.LauncherUI) {
|
if (window.LauncherUI) {
|
||||||
window.LauncherUI.updateProgress({
|
window.LauncherUI.updateProgress({
|
||||||
percent: progress,
|
percent: progress,
|
||||||
message: progress < 100 ? 'Installing game files...' : 'Installation complete!',
|
message: progress < 100 ? installingMsg : completeMsg,
|
||||||
speed: 1024 * 1024 * (5 + Math.random() * 10),
|
speed: 1024 * 1024 * (5 + Math.random() * 10),
|
||||||
downloaded: progress * 1024 * 1024 * 20,
|
downloaded: progress * 1024 * 1024 * 20,
|
||||||
total: 1024 * 1024 * 2000
|
total: 1024 * 1024 * 2000
|
||||||
@@ -112,9 +118,10 @@ function simulateInstallation(playerName) {
|
|||||||
|
|
||||||
if (progress >= 100) {
|
if (progress >= 100) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
const successMsg = window.i18n ? window.i18n.t('progress.installationComplete') : 'Installation completed successfully!';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.LauncherUI) {
|
if (window.LauncherUI) {
|
||||||
window.LauncherUI.updateProgress({ message: 'Installation completed successfully!' });
|
window.LauncherUI.updateProgress({ message: successMsg });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.LauncherUI.hideProgress();
|
window.LauncherUI.hideProgress();
|
||||||
window.LauncherUI.showLauncherOrInstall(true);
|
window.LauncherUI.showLauncherOrInstall(true);
|
||||||
@@ -134,6 +141,35 @@ function resetInstallButton() {
|
|||||||
installBtn.disabled = false;
|
installBtn.disabled = false;
|
||||||
installText.textContent = 'INSTALL HYTALE';
|
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() {
|
export async function browseInstallPath() {
|
||||||
@@ -210,3 +246,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
setupInstallation();
|
setupInstallation();
|
||||||
await checkGameStatusAndShowInterface();
|
await checkGameStatusAndShowInterface();
|
||||||
});
|
});
|
||||||
|
window.browseInstallPath = browseInstallPath;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
setupInstallation();
|
||||||
|
await checkGameStatusAndShowInterface();
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,111 +14,415 @@ export function setupLauncher() {
|
|||||||
uninstallBtn = document.getElementById('uninstallBtn');
|
uninstallBtn = document.getElementById('uninstallBtn');
|
||||||
playerNameInput = document.getElementById('playerName');
|
playerNameInput = document.getElementById('playerName');
|
||||||
javaPathInput = document.getElementById('javaPath');
|
javaPathInput = document.getElementById('javaPath');
|
||||||
|
|
||||||
if (playerNameInput) {
|
if (playerNameInput) {
|
||||||
playerNameInput.addEventListener('change', savePlayerName);
|
playerNameInput.addEventListener('change', savePlayerName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (javaPathInput) {
|
if (javaPathInput) {
|
||||||
javaPathInput.addEventListener('change', saveJavaPath);
|
javaPathInput.addEventListener('change', saveJavaPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.onProgressUpdate) {
|
if (window.electronAPI && window.electronAPI.onProgressUpdate) {
|
||||||
window.electronAPI.onProgressUpdate((data) => {
|
window.electronAPI.onProgressUpdate((data) => {
|
||||||
|
if (!isDownloading) return;
|
||||||
if (window.LauncherUI) {
|
if (window.LauncherUI) {
|
||||||
window.LauncherUI.showProgress();
|
|
||||||
window.LauncherUI.updateProgress(data);
|
window.LauncherUI.updateProgress(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.onProgressComplete) {
|
// Initial Profile Load
|
||||||
window.electronAPI.onProgressComplete(() => {
|
loadProfiles();
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.hideProgress();
|
// Close dropdown on outside click
|
||||||
}
|
document.addEventListener('click', (e) => {
|
||||||
resetPlayButton();
|
const selector = document.getElementById('profileSelector');
|
||||||
});
|
if (selector && !selector.contains(e.target)) {
|
||||||
|
const dropdown = document.getElementById('profileDropdown');
|
||||||
|
if (dropdown) dropdown.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// PROFILE MANAGEMENT
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async function loadProfiles() {
|
||||||
|
try {
|
||||||
|
if (!window.electronAPI || !window.electronAPI.profile) return;
|
||||||
|
|
||||||
|
const profiles = await window.electronAPI.profile.list();
|
||||||
|
const activeProfile = await window.electronAPI.profile.getActive();
|
||||||
|
|
||||||
|
renderProfileList(profiles, activeProfile);
|
||||||
|
updateCurrentProfileUI(activeProfile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load profiles:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderProfileList(profiles, activeProfile) {
|
||||||
|
const list = document.getElementById('profileList');
|
||||||
|
const managerList = document.getElementById('managerProfileList');
|
||||||
|
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
// Dropdown List
|
||||||
|
list.innerHTML = profiles.map(p => `
|
||||||
|
<div class="profile-item ${p.id === activeProfile.id ? 'active' : ''}"
|
||||||
|
onclick="switchProfile('${p.id}')">
|
||||||
|
<span>${p.name}</span>
|
||||||
|
${p.id === activeProfile.id ? '<i class="fas fa-check ml-auto"></i>' : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Manager Modal List
|
||||||
|
if (managerList) {
|
||||||
|
managerList.innerHTML = profiles.map(p => `
|
||||||
|
<div class="profile-manager-item ${p.id === activeProfile.id ? 'active' : ''}">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<i class="fas fa-user-circle text-xl text-gray-400"></i>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold">${p.name}</div>
|
||||||
|
<div class="text-xs text-gray-500">ID: ${p.id.substring(0, 8)}...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${p.id !== activeProfile.id ? `
|
||||||
|
<button class="profile-delete-btn" onclick="deleteProfile('${p.id}')" title="Delete Profile">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
` : '<span class="text-xs text-green-500 font-bold px-2">ACTIVE</span>'}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCurrentProfileUI(profile) {
|
||||||
|
const nameEl = document.getElementById('currentProfileName');
|
||||||
|
if (nameEl && profile) {
|
||||||
|
nameEl.textContent = profile.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.toggleProfileDropdown = () => {
|
||||||
|
const dropdown = document.getElementById('profileDropdown');
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.classList.toggle('show');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.openProfileManager = () => {
|
||||||
|
const modal = document.getElementById('profileManagerModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
// Refresh list
|
||||||
|
loadProfiles();
|
||||||
|
}
|
||||||
|
// Close dropdown
|
||||||
|
const dropdown = document.getElementById('profileDropdown');
|
||||||
|
if (dropdown) dropdown.classList.remove('show');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeProfileManager = () => {
|
||||||
|
const modal = document.getElementById('profileManagerModal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.createNewProfile = async () => {
|
||||||
|
const input = document.getElementById('newProfileName');
|
||||||
|
if (!input || !input.value.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const name = input.value.trim();
|
||||||
|
await window.electronAPI.profile.create(name);
|
||||||
|
input.value = '';
|
||||||
|
await loadProfiles();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create profile:', error);
|
||||||
|
alert('Failed to create profile: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.deleteProfile = async (id) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this profile? parameters and mods configuration will be lost.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.electronAPI.profile.delete(id);
|
||||||
|
await loadProfiles();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete profile:', error);
|
||||||
|
alert('Failed to delete profile: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.switchProfile = async (id) => {
|
||||||
|
try {
|
||||||
|
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||||
|
const switchingMsg = window.i18n ? window.i18n.t('progress.switchingProfile') : 'Switching Profile...';
|
||||||
|
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: switchingMsg });
|
||||||
|
|
||||||
|
await window.electronAPI.profile.activate(id);
|
||||||
|
|
||||||
|
// Refresh UI
|
||||||
|
await loadProfiles();
|
||||||
|
|
||||||
|
// Refresh Mods
|
||||||
|
if (window.modsManager) {
|
||||||
|
if (window.modsManager.loadInstalledMods) await window.modsManager.loadInstalledMods();
|
||||||
|
if (window.modsManager.loadBrowseMods) await window.modsManager.loadBrowseMods();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown
|
||||||
|
const dropdown = document.getElementById('profileDropdown');
|
||||||
|
if (dropdown) dropdown.classList.remove('show');
|
||||||
|
|
||||||
|
if (window.LauncherUI) {
|
||||||
|
const switchedMsg = window.i18n ? window.i18n.t('progress.profileSwitched') : 'Profile Switched!';
|
||||||
|
window.LauncherUI.updateProgress({ message: switchedMsg });
|
||||||
|
setTimeout(() => window.LauncherUI.hideProgress(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to switch profile:', error);
|
||||||
|
alert('Failed to switch profile: ' + error.message);
|
||||||
|
if (window.LauncherUI) window.LauncherUI.hideProgress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export async function launch() {
|
export async function launch() {
|
||||||
if (isDownloading || (playBtn && playBtn.disabled)) return;
|
if (isDownloading || (playBtn && playBtn.disabled)) return;
|
||||||
|
|
||||||
let playerName = 'Player';
|
let playerName = 'Player';
|
||||||
if (window.SettingsAPI && window.SettingsAPI.getCurrentPlayerName) {
|
if (window.SettingsAPI && window.SettingsAPI.getCurrentPlayerName) {
|
||||||
playerName = window.SettingsAPI.getCurrentPlayerName();
|
playerName = window.SettingsAPI.getCurrentPlayerName();
|
||||||
} else if (playerNameInput && playerNameInput.value.trim()) {
|
} else if (playerNameInput && playerNameInput.value.trim()) {
|
||||||
playerName = playerNameInput.value.trim();
|
playerName = playerNameInput.value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
let javaPath = '';
|
let javaPath = '';
|
||||||
if (window.SettingsAPI && window.SettingsAPI.getCurrentJavaPath) {
|
if (window.SettingsAPI && window.SettingsAPI.getCurrentJavaPath) {
|
||||||
javaPath = window.SettingsAPI.getCurrentJavaPath();
|
javaPath = window.SettingsAPI.getCurrentJavaPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let gpuPreference = 'auto';
|
||||||
|
try {
|
||||||
|
if (window.electronAPI && window.electronAPI.loadGpuPreference) {
|
||||||
|
gpuPreference = await window.electronAPI.loadGpuPreference();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading GPU preference:', error);
|
||||||
|
}
|
||||||
|
|
||||||
if (window.LauncherUI) window.LauncherUI.showProgress();
|
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||||
isDownloading = true;
|
isDownloading = true;
|
||||||
if (playBtn) {
|
if (playBtn) {
|
||||||
playBtn.disabled = true;
|
playBtn.disabled = true;
|
||||||
playText.textContent = 'LAUNCHING...';
|
playText.textContent = 'LAUNCHING...';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const startingMsg = window.i18n ? window.i18n.t('progress.startingGame') : 'Starting game...';
|
||||||
|
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: startingMsg });
|
||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.launchGame) {
|
if (window.electronAPI && window.electronAPI.launchGame) {
|
||||||
const result = await window.electronAPI.launchGame(playerName, javaPath, '');
|
const result = await window.electronAPI.launchGame(playerName, javaPath, '', gpuPreference);
|
||||||
|
|
||||||
|
isDownloading = false;
|
||||||
|
|
||||||
|
if (window.LauncherUI) {
|
||||||
|
window.LauncherUI.hideProgress();
|
||||||
|
}
|
||||||
|
resetPlayButton();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (window.LauncherUI) {
|
if (window.electronAPI.minimizeWindow) {
|
||||||
window.LauncherUI.updateProgress({ message: 'Game started successfully!' });
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.LauncherUI.hideProgress();
|
window.electronAPI.minimizeWindow();
|
||||||
if (window.electronAPI.minimizeWindow) {
|
}, 500);
|
||||||
window.electronAPI.minimizeWindow();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Launch failed');
|
console.error('Launch failed:', result.error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setTimeout(() => {
|
isDownloading = false;
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.updateProgress({ message: 'Game started successfully!' });
|
if (window.LauncherUI) {
|
||||||
setTimeout(() => {
|
window.LauncherUI.hideProgress();
|
||||||
window.LauncherUI.hideProgress();
|
}
|
||||||
resetPlayButton();
|
resetPlayButton();
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
isDownloading = false;
|
||||||
|
|
||||||
if (window.LauncherUI) {
|
if (window.LauncherUI) {
|
||||||
window.LauncherUI.updateProgress({ message: `Failed: ${error.message}` });
|
window.LauncherUI.hideProgress();
|
||||||
setTimeout(() => {
|
|
||||||
window.LauncherUI.hideProgress();
|
|
||||||
resetPlayButton();
|
|
||||||
}, 3000);
|
|
||||||
}
|
}
|
||||||
|
resetPlayButton();
|
||||||
|
console.error('Launch error:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uninstallGame() {
|
function showCustomConfirm(message, title, onConfirm, onCancel = null, confirmText, cancelText) {
|
||||||
if (!confirm('Are you sure you want to uninstall Hytale? All game files will be deleted.')) {
|
// Apply defaults with i18n support
|
||||||
return;
|
title = title || (window.i18n ? window.i18n.t('confirm.defaultTitle') : 'Confirm Action');
|
||||||
|
confirmText = confirmText || (window.i18n ? window.i18n.t('common.confirm') : 'Confirm');
|
||||||
|
cancelText = cancelText || (window.i18n ? window.i18n.t('common.cancel') : 'Cancel');
|
||||||
|
|
||||||
|
const existingModal = document.querySelector('.custom-confirm-modal');
|
||||||
|
if (existingModal) {
|
||||||
|
existingModal.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'custom-confirm-modal';
|
||||||
|
modal.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 20000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dialog = document.createElement('div');
|
||||||
|
dialog.className = 'custom-confirm-dialog';
|
||||||
|
dialog.style.cssText = `
|
||||||
|
background: #1f2937;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
transform: scale(0.9);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
`;
|
||||||
|
|
||||||
|
dialog.innerHTML = `
|
||||||
|
<div style="padding: 24px; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px; color: #ef4444;">
|
||||||
|
<i class="fas fa-exclamation-triangle" style="font-size: 24px;"></i>
|
||||||
|
<h3 style="margin: 0; font-size: 1.2rem; font-weight: 600;">${title}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 24px; color: #e5e7eb;">
|
||||||
|
<p style="margin: 0; line-height: 1.5; font-size: 1rem;">${message}</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 20px 24px; display: flex; gap: 12px; justify-content: flex-end; border-top: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
<button class="custom-confirm-cancel" style="
|
||||||
|
background: transparent;
|
||||||
|
color: #9ca3af;
|
||||||
|
border: 1px solid rgba(156, 163, 175, 0.3);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
">${cancelText}</button>
|
||||||
|
<button class="custom-confirm-action" style="
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
">${confirmText}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.appendChild(dialog);
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.style.opacity = '1';
|
||||||
|
dialog.style.transform = 'scale(1)';
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const cancelBtn = dialog.querySelector('.custom-confirm-cancel');
|
||||||
|
const actionBtn = dialog.querySelector('.custom-confirm-action');
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modal.style.opacity = '0';
|
||||||
|
dialog.style.transform = 'scale(0.9)';
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.remove();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelBtn.onclick = () => {
|
||||||
|
closeModal();
|
||||||
|
if (onCancel) onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
actionBtn.onclick = () => {
|
||||||
|
closeModal();
|
||||||
|
onConfirm();
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.onclick = (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
if (onCancel) onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Escape key
|
||||||
|
const handleEscape = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
if (onCancel) onCancel();
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uninstallGame() {
|
||||||
|
const message = window.i18n ? window.i18n.t('confirm.uninstallGameMessage') : 'Are you sure you want to uninstall Hytale? All game files will be deleted.';
|
||||||
|
const title = window.i18n ? window.i18n.t('confirm.uninstallGameTitle') : 'Uninstall Game';
|
||||||
|
const confirmBtn = window.i18n ? window.i18n.t('confirm.uninstallGameButton') : 'Uninstall';
|
||||||
|
const cancelBtn = window.i18n ? window.i18n.t('common.cancel') : 'Cancel';
|
||||||
|
|
||||||
|
showCustomConfirm(
|
||||||
|
message,
|
||||||
|
title,
|
||||||
|
async () => {
|
||||||
|
await performUninstall();
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
confirmBtn,
|
||||||
|
cancelBtn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performUninstall() {
|
||||||
|
|
||||||
if (window.LauncherUI) window.LauncherUI.showProgress();
|
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||||
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: 'Uninstalling game...' });
|
const uninstallingMsg = window.i18n ? window.i18n.t('progress.uninstallingGame') : 'Uninstalling game...';
|
||||||
|
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: uninstallingMsg });
|
||||||
if (uninstallBtn) uninstallBtn.disabled = true;
|
if (uninstallBtn) uninstallBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (window.electronAPI && window.electronAPI.uninstallGame) {
|
if (window.electronAPI && window.electronAPI.uninstallGame) {
|
||||||
const result = await window.electronAPI.uninstallGame();
|
const result = await window.electronAPI.uninstallGame();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
const successMsg = window.i18n ? window.i18n.t('progress.gameUninstalled') : 'Game uninstalled successfully!';
|
||||||
if (window.LauncherUI) {
|
if (window.LauncherUI) {
|
||||||
window.LauncherUI.updateProgress({ message: 'Game uninstalled successfully!' });
|
window.LauncherUI.updateProgress({ message: successMsg });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.LauncherUI.hideProgress();
|
window.LauncherUI.hideProgress();
|
||||||
window.LauncherUI.showLauncherOrInstall(false);
|
window.LauncherUI.showLauncherOrInstall(false);
|
||||||
@@ -128,9 +432,10 @@ export async function uninstallGame() {
|
|||||||
throw new Error(result.error || 'Uninstall failed');
|
throw new Error(result.error || 'Uninstall failed');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const successMsg = window.i18n ? window.i18n.t('progress.gameUninstalled') : 'Game uninstalled successfully!';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.LauncherUI) {
|
if (window.LauncherUI) {
|
||||||
window.LauncherUI.updateProgress({ message: 'Game uninstalled successfully!' });
|
window.LauncherUI.updateProgress({ message: successMsg });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.LauncherUI.hideProgress();
|
window.LauncherUI.hideProgress();
|
||||||
window.LauncherUI.showLauncherOrInstall(false);
|
window.LauncherUI.showLauncherOrInstall(false);
|
||||||
@@ -139,8 +444,9 @@ export async function uninstallGame() {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMsg = window.i18n ? window.i18n.t('progress.uninstallFailed').replace('{error}', error.message) : `Uninstall failed: ${error.message}`;
|
||||||
if (window.LauncherUI) {
|
if (window.LauncherUI) {
|
||||||
window.LauncherUI.updateProgress({ message: `Uninstall failed: ${error.message}` });
|
window.LauncherUI.updateProgress({ message: errorMsg });
|
||||||
setTimeout(() => window.LauncherUI.hideProgress(), 3000);
|
setTimeout(() => window.LauncherUI.hideProgress(), 3000);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -148,11 +454,54 @@ export async function uninstallGame() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function repairGame() {
|
||||||
|
showCustomConfirm(
|
||||||
|
'Are you sure you want to repair Hytale? This will reinstall the game files but keep your data (saves, screenshots, etc.).',
|
||||||
|
'Repair Game',
|
||||||
|
async () => {
|
||||||
|
await performRepair();
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
'Repair',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performRepair() {
|
||||||
|
if (window.LauncherUI) window.LauncherUI.showProgress();
|
||||||
|
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: 'Repairing game...' });
|
||||||
|
isDownloading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (window.electronAPI && window.electronAPI.repairGame) {
|
||||||
|
const result = await window.electronAPI.repairGame();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (window.LauncherUI) {
|
||||||
|
window.LauncherUI.updateProgress({ message: 'Game repaired successfully!' });
|
||||||
|
setTimeout(() => {
|
||||||
|
window.LauncherUI.hideProgress();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Repair failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (window.LauncherUI) {
|
||||||
|
window.LauncherUI.updateProgress({ message: `Repair failed: ${error.message}` });
|
||||||
|
setTimeout(() => window.LauncherUI.hideProgress(), 3000);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isDownloading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetPlayButton() {
|
function resetPlayButton() {
|
||||||
isDownloading = false;
|
isDownloading = false;
|
||||||
if (playBtn) {
|
if (playBtn) {
|
||||||
playBtn.disabled = false;
|
playBtn.disabled = false;
|
||||||
playText.textContent = 'PLAY';
|
playText.textContent = window.i18n ? window.i18n.t('play.play') : 'PLAY';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +529,7 @@ async function saveJavaPath() {
|
|||||||
|
|
||||||
function toggleCustomJava() {
|
function toggleCustomJava() {
|
||||||
if (!customJavaOptions) return;
|
if (!customJavaOptions) return;
|
||||||
|
|
||||||
if (customJavaCheck && customJavaCheck.checked) {
|
if (customJavaCheck && customJavaCheck.checked) {
|
||||||
customJavaOptions.style.display = 'block';
|
customJavaOptions.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
@@ -240,5 +589,59 @@ async function loadCustomJavaPath() {
|
|||||||
|
|
||||||
window.launch = launch;
|
window.launch = launch;
|
||||||
window.uninstallGame = uninstallGame;
|
window.uninstallGame = uninstallGame;
|
||||||
|
window.repairGame = repairGame;
|
||||||
|
|
||||||
|
window.openLogs = async () => {
|
||||||
|
if (window.LauncherUI) {
|
||||||
|
window.LauncherUI.showPage('logs-page');
|
||||||
|
window.LauncherUI.setActiveNav('logs');
|
||||||
|
}
|
||||||
|
await refreshLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.openLogsFolder = async () => {
|
||||||
|
try {
|
||||||
|
if (window.electronAPI && window.electronAPI.openLogsFolder) {
|
||||||
|
await window.electronAPI.openLogsFolder();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open logs folder:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.refreshLogs = async () => {
|
||||||
|
const terminal = document.getElementById('logsTerminal');
|
||||||
|
if (!terminal) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (window.electronAPI && window.electronAPI.getRecentLogs) {
|
||||||
|
// Fetch up to MAX_LOG_LINES lines
|
||||||
|
const logs = await window.electronAPI.getRecentLogs(MAX_LOG_LINES);
|
||||||
|
if (logs) {
|
||||||
|
// Formatting for colors could be done here if needed
|
||||||
|
terminal.textContent = logs;
|
||||||
|
terminal.scrollTop = terminal.scrollHeight;
|
||||||
|
} else {
|
||||||
|
terminal.textContent = 'No logs available.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
terminal.textContent = 'Error loading logs: ' + error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.copyLogs = () => {
|
||||||
|
const terminal = document.getElementById('logsTerminal');
|
||||||
|
if (terminal) {
|
||||||
|
navigator.clipboard.writeText(terminal.textContent)
|
||||||
|
.then(() => alert('Logs copied to clipboard!'))
|
||||||
|
.catch(err => console.error('Failed to copy logs:', err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.repairGame = repairGame;
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const MAX_LOG_LINES = 500;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', setupLauncher);
|
document.addEventListener('DOMContentLoaded', setupLauncher);
|
||||||
|
|||||||
96
GUI/js/logs.js
Normal file
96
GUI/js/logs.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
|
||||||
|
// Logs Page Logic
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
const terminal = document.getElementById('logsTerminal');
|
||||||
|
if (!terminal) return;
|
||||||
|
|
||||||
|
terminal.innerHTML = '<div class="text-gray-500 text-center mt-10">Loading logs...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logs = await window.electronAPI.getRecentLogs(500); // Fetch last 500 lines
|
||||||
|
|
||||||
|
if (logs) {
|
||||||
|
// Escape HTML to prevent XSS and preserve format
|
||||||
|
const safeLogs = logs.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
terminal.innerHTML = `<pre class="logs-content">${safeLogs}</pre>`;
|
||||||
|
|
||||||
|
// Auto scroll to bottom
|
||||||
|
terminal.scrollTop = terminal.scrollHeight;
|
||||||
|
} else {
|
||||||
|
terminal.innerHTML = '<div class="text-gray-500 text-center mt-10">No logs found.</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load logs:', error);
|
||||||
|
terminal.innerHTML = `<div class="text-red-500 text-center mt-10">Error loading logs: ${error.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLogs() {
|
||||||
|
const btn = document.querySelector('button[onclick="refreshLogs()"] i');
|
||||||
|
if (btn) btn.classList.add('fa-spin');
|
||||||
|
|
||||||
|
await loadLogs();
|
||||||
|
|
||||||
|
if (btn) setTimeout(() => btn.classList.remove('fa-spin'), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLogs() {
|
||||||
|
const terminal = document.getElementById('logsTerminal');
|
||||||
|
if (!terminal) return;
|
||||||
|
|
||||||
|
const content = terminal.innerText;
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
|
||||||
|
const btn = document.querySelector('button[onclick="copyLogs()"]');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy logs:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLogsFolder() {
|
||||||
|
await window.electronAPI.openLogsFolder();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLogs() {
|
||||||
|
// Navigation is handled by sidebar logic, but we can trigger a refresh
|
||||||
|
window.LauncherUI.showPage('logs-page');
|
||||||
|
window.LauncherUI.setActiveNav('logs');
|
||||||
|
refreshLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose functions globally
|
||||||
|
window.refreshLogs = refreshLogs;
|
||||||
|
window.copyLogs = copyLogs;
|
||||||
|
window.openLogsFolder = openLogsFolder;
|
||||||
|
window.openLogs = openLogs;
|
||||||
|
|
||||||
|
// Auto-load logs when the page becomes active
|
||||||
|
const logsObserver = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.target.classList.contains('active') && mutation.target.id === 'logs-page') {
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const logsPage = document.getElementById('logs-page');
|
||||||
|
if (logsPage) {
|
||||||
|
logsObserver.observe(logsPage, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
202
GUI/js/mods.js
202
GUI/js/mods.js
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
const API_KEY = '$2a$10$bqk254NMZOWVTzLVJCcxEOmhcyUujKxA5xk.kQCN9q0KNYFJd5b32';
|
let API_KEY = "$2a$10$bqk254NMZOWVTzLVJCcxEOmhcyUujKxA5xk.kQCN9q0KNYFJd5b32";
|
||||||
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
|
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
|
||||||
const HYTALE_GAME_ID = 70216;
|
const HYTALE_GAME_ID = 70216;
|
||||||
|
|
||||||
@@ -11,6 +11,14 @@ let modsPageSize = 20;
|
|||||||
let modsTotalPages = 1;
|
let modsTotalPages = 1;
|
||||||
|
|
||||||
export async function initModsManager() {
|
export async function initModsManager() {
|
||||||
|
try {
|
||||||
|
if (window.electronAPI && window.electronAPI.getEnvVar) {
|
||||||
|
console.log('Loaded API Key:', API_KEY ? 'Yes' : 'No');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load API Key:', err);
|
||||||
|
}
|
||||||
|
|
||||||
setupModsEventListeners();
|
setupModsEventListeners();
|
||||||
await loadInstalledMods();
|
await loadInstalledMods();
|
||||||
await loadBrowseMods();
|
await loadBrowseMods();
|
||||||
@@ -22,10 +30,10 @@ function setupModsEventListeners() {
|
|||||||
let searchTimeout;
|
let searchTimeout;
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener('input', (e) => {
|
||||||
searchQuery = e.target.value.toLowerCase().trim();
|
searchQuery = e.target.value.toLowerCase().trim();
|
||||||
|
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
modsPage = 0;
|
modsPage = 0;
|
||||||
loadBrowseMods();
|
loadBrowseMods();
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
@@ -52,7 +60,7 @@ function setupModsEventListeners() {
|
|||||||
|
|
||||||
const prevPageBtn = document.getElementById('prevPage');
|
const prevPageBtn = document.getElementById('prevPage');
|
||||||
const nextPageBtn = document.getElementById('nextPage');
|
const nextPageBtn = document.getElementById('nextPage');
|
||||||
|
|
||||||
if (prevPageBtn) {
|
if (prevPageBtn) {
|
||||||
prevPageBtn.addEventListener('click', () => {
|
prevPageBtn.addEventListener('click', () => {
|
||||||
if (modsPage > 0) {
|
if (modsPage > 0) {
|
||||||
@@ -61,7 +69,7 @@ function setupModsEventListeners() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextPageBtn) {
|
if (nextPageBtn) {
|
||||||
nextPageBtn.addEventListener('click', () => {
|
nextPageBtn.addEventListener('click', () => {
|
||||||
if (modsPage < modsTotalPages - 1) {
|
if (modsPage < modsTotalPages - 1) {
|
||||||
@@ -97,7 +105,7 @@ async function loadInstalledMods() {
|
|||||||
|
|
||||||
const mods = await window.electronAPI?.loadInstalledMods(modsPath);
|
const mods = await window.electronAPI?.loadInstalledMods(modsPath);
|
||||||
installedMods = mods || [];
|
installedMods = mods || [];
|
||||||
|
|
||||||
displayInstalledMods(installedMods);
|
displayInstalledMods(installedMods);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading installed mods:', error);
|
console.error('Error loading installed mods:', error);
|
||||||
@@ -113,10 +121,15 @@ function displayInstalledMods(mods) {
|
|||||||
modsContainer.innerHTML = `
|
modsContainer.innerHTML = `
|
||||||
<div class=\"empty-installed-mods\">
|
<div class=\"empty-installed-mods\">
|
||||||
<i class=\"fas fa-box-open\"></i>
|
<i class=\"fas fa-box-open\"></i>
|
||||||
<h4>No Mods Installed</h4>
|
<h4 data-i18n="mods.noModsInstalled">No Mods Installed</h4>
|
||||||
<p>Add mods from CurseForge or import local files</p>
|
<p data-i18n="mods.noModsInstalledDesc">Add mods from CurseForge or import local files</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
if (window.i18n) {
|
||||||
|
const container = modsContainer.querySelector('.empty-installed-mods');
|
||||||
|
container.querySelector('h4').textContent = window.i18n.t('mods.noModsInstalled');
|
||||||
|
container.querySelector('p').textContent = window.i18n.t('mods.noModsInstalledDesc');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +151,9 @@ function displayInstalledMods(mods) {
|
|||||||
|
|
||||||
function createInstalledModCard(mod) {
|
function createInstalledModCard(mod) {
|
||||||
const statusClass = mod.enabled ? 'text-primary' : 'text-zinc-500';
|
const statusClass = mod.enabled ? 'text-primary' : 'text-zinc-500';
|
||||||
const statusText = mod.enabled ? 'ACTIVE' : 'DISABLED';
|
const statusText = mod.enabled ? (window.i18n ? window.i18n.t('mods.active') : 'ACTIVE') : (window.i18n ? window.i18n.t('mods.disabled') : 'DISABLED');
|
||||||
const toggleBtnClass = mod.enabled ? 'btn-disable' : 'btn-enable';
|
const toggleBtnClass = mod.enabled ? 'btn-disable' : 'btn-enable';
|
||||||
const toggleBtnText = mod.enabled ? 'DISABLE' : 'ENABLE';
|
const toggleBtnText = mod.enabled ? (window.i18n ? window.i18n.t('mods.disable') : 'DISABLE') : (window.i18n ? window.i18n.t('mods.enable') : 'ENABLE');
|
||||||
const toggleIcon = mod.enabled ? 'fa-pause' : 'fa-play';
|
const toggleIcon = mod.enabled ? 'fa-pause' : 'fa-play';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -154,7 +167,7 @@ function createInstalledModCard(mod) {
|
|||||||
<h4 class="installed-mod-name">${mod.name}</h4>
|
<h4 class="installed-mod-name">${mod.name}</h4>
|
||||||
<span class="installed-mod-version">v${mod.version}</span>
|
<span class="installed-mod-version">v${mod.version}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="installed-mod-description">${mod.description || 'No description available'}</p>
|
<p class="installed-mod-description">${mod.description || (window.i18n ? window.i18n.t('mods.noDescription') : 'No description available')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="installed-mod-actions">
|
<div class="installed-mod-actions">
|
||||||
@@ -163,7 +176,7 @@ function createInstalledModCard(mod) {
|
|||||||
${statusText}
|
${statusText}
|
||||||
</div>
|
</div>
|
||||||
<div class="installed-mod-buttons">
|
<div class="installed-mod-buttons">
|
||||||
<button id="delete-installed-${mod.id}" class="installed-mod-btn-icon" title="Delete mod">
|
<button id="delete-installed-${mod.id}" class="installed-mod-btn-icon" title="${window.i18n ? window.i18n.t('mods.delete') : 'Delete mod'}">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
<button id="toggle-installed-${mod.id}" class="installed-mod-btn-toggle ${toggleBtnClass}">
|
<button id="toggle-installed-${mod.id}" class="installed-mod-btn-toggle ${toggleBtnClass}">
|
||||||
@@ -180,29 +193,34 @@ async function loadBrowseMods() {
|
|||||||
const browseContainer = document.getElementById('browseModsList');
|
const browseContainer = document.getElementById('browseModsList');
|
||||||
if (!browseContainer) return;
|
if (!browseContainer) return;
|
||||||
|
|
||||||
browseContainer.innerHTML = '<div class=\"loading-mods\"><div class=\"loading-spinner\"></div><span>Loading mods from CurseForge...</span></div>';
|
browseContainer.innerHTML = `<div class="loading-mods"><div class="loading-spinner"></div><span>${window.i18n ? window.i18n.t('mods.loadingMods') : 'Loading mods from CurseForge...'}</span></div>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!API_KEY || API_KEY.length < 10) {
|
if (!API_KEY || API_KEY.length < 10) {
|
||||||
browseContainer.innerHTML = `
|
browseContainer.innerHTML = `
|
||||||
<div class=\"empty-browse-mods\">
|
<div class=\"empty-browse-mods\">
|
||||||
<i class=\"fas fa-key\"></i>
|
<i class=\"fas fa-key\"></i>
|
||||||
<h4>API Key Required</h4>
|
<h4 data-i18n="mods.apiKeyRequired">API Key Required</h4>
|
||||||
<p>CurseForge API key is needed to browse mods</p>
|
<p data-i18n="mods.apiKeyRequiredDesc">CurseForge API key is needed to browse mods</p>
|
||||||
</div>
|
</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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = modsPage * modsPageSize;
|
const offset = modsPage * modsPageSize;
|
||||||
let url = `${CURSEFORGE_API}/mods/search?gameId=${HYTALE_GAME_ID}&pageSize=${modsPageSize}&sortOrder=desc&sortField=6&index=${offset}`;
|
let url = `${CURSEFORGE_API}/mods/search?gameId=${HYTALE_GAME_ID}&pageSize=${modsPageSize}&sortOrder=desc&sortField=6&index=${offset}`;
|
||||||
|
|
||||||
if (searchQuery && searchQuery.length > 0) {
|
if (searchQuery && searchQuery.length > 0) {
|
||||||
url += `&searchFilter=${encodeURIComponent(searchQuery)}`;
|
url += `&searchFilter=${encodeURIComponent(searchQuery)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Fetching mods from page', modsPage + 1, 'offset:', offset, 'search:', searchQuery || 'none', 'URL:', url);
|
console.log('Fetching mods from page', modsPage + 1, 'offset:', offset, 'search:', searchQuery || 'none', 'URL:', url);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': API_KEY,
|
'x-api-key': API_KEY,
|
||||||
@@ -221,7 +239,7 @@ async function loadBrowseMods() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('API Response data:', data);
|
console.log('API Response data:', data);
|
||||||
console.log('Total mods found:', data.data?.length || 0);
|
console.log('Total mods found:', data.data?.length || 0);
|
||||||
|
|
||||||
browseMods = (data.data || []).map(mod => ({
|
browseMods = (data.data || []).map(mod => ({
|
||||||
id: mod.id.toString(),
|
id: mod.id.toString(),
|
||||||
name: mod.name,
|
name: mod.name,
|
||||||
@@ -264,10 +282,15 @@ function displayBrowseMods(mods) {
|
|||||||
browseContainer.innerHTML = `
|
browseContainer.innerHTML = `
|
||||||
<div class=\"empty-browse-mods\">
|
<div class=\"empty-browse-mods\">
|
||||||
<i class=\"fas fa-search\"></i>
|
<i class=\"fas fa-search\"></i>
|
||||||
<h4>No Mods Found</h4>
|
<h4 data-i18n="mods.noModsFound">No Mods Found</h4>
|
||||||
<p>Try adjusting your search</p>
|
<p data-i18n="mods.noModsFoundDesc">Try adjusting your search</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
if (window.i18n) {
|
||||||
|
const container = browseContainer.querySelector('.empty-browse-mods');
|
||||||
|
container.querySelector('h4').textContent = window.i18n.t('mods.noModsFound');
|
||||||
|
container.querySelector('p').textContent = window.i18n.t('mods.noModsFoundDesc');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,18 +305,25 @@ function displayBrowseMods(mods) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createBrowseModCard(mod) {
|
function createBrowseModCard(mod) {
|
||||||
const isInstalled = installedMods.some(installed =>
|
const isInstalled = installedMods.some(installed => {
|
||||||
installed.name.toLowerCase().includes(mod.name.toLowerCase()) ||
|
// Check by CurseForge ID (most reliable)
|
||||||
installed.curseForgeId == mod.id
|
if (installed.curseForgeId && installed.curseForgeId.toString() === mod.id.toString()) {
|
||||||
);
|
return true;
|
||||||
|
}
|
||||||
|
// Check by exact name match for manually installed mods
|
||||||
|
if (installed.name.toLowerCase() === mod.name.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class=\"mod-card ${isInstalled ? 'installed' : ''}\" data-mod-id=\"${mod.id}\">
|
<div class=\"mod-card ${isInstalled ? 'installed' : ''}\" data-mod-id=\"${mod.id}\">
|
||||||
<div class=\"mod-image\">
|
<div class=\"mod-image\">
|
||||||
${mod.thumbnailUrl ?
|
${mod.thumbnailUrl ?
|
||||||
`<img src=\"${mod.thumbnailUrl}\" alt=\"${mod.name}\" onerror=\"this.parentElement.innerHTML='<i class=\\\"fas fa-puzzle-piece\\\"></i>'\">` :
|
`<img src=\"${mod.thumbnailUrl}\" alt=\"${mod.name}\" onerror=\"this.parentElement.innerHTML='<i class=\\\"fas fa-puzzle-piece\\\"></i>'\">` :
|
||||||
`<i class=\"fas fa-puzzle-piece\"></i>`
|
`<i class=\"fas fa-puzzle-piece\"></i>`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=\"mod-info\">
|
<div class=\"mod-info\">
|
||||||
@@ -317,18 +347,18 @@ function createBrowseModCard(mod) {
|
|||||||
<div class=\"mod-actions\">
|
<div class=\"mod-actions\">
|
||||||
<button id=\"view-${mod.id}\" class=\"mod-btn-toggle bg-blue-600 text-white hover:bg-blue-700\" onclick=\"window.modsManager.viewModPage(${mod.id})\">
|
<button id=\"view-${mod.id}\" class=\"mod-btn-toggle bg-blue-600 text-white hover:bg-blue-700\" onclick=\"window.modsManager.viewModPage(${mod.id})\">
|
||||||
<i class=\"fas fa-external-link-alt\"></i>
|
<i class=\"fas fa-external-link-alt\"></i>
|
||||||
VIEW
|
${window.i18n ? window.i18n.t('mods.view') : 'VIEW'}
|
||||||
</button>
|
</button>
|
||||||
${!isInstalled ?
|
${!isInstalled ?
|
||||||
`<button id=\"install-${mod.id}\" class=\"mod-btn-toggle bg-primary text-black hover:bg-primary/80\">
|
`<button id="install-${mod.id}" class="mod-btn-toggle bg-primary text-black hover:bg-primary/80">
|
||||||
<i class=\"fas fa-download\"></i>
|
<i class="fas fa-download"></i>
|
||||||
INSTALL
|
${window.i18n ? window.i18n.t('mods.install') : 'INSTALL'}
|
||||||
</button>` :
|
</button>` :
|
||||||
`<button class=\"mod-btn-toggle bg-white/10 text-white\" disabled>
|
`<button class="mod-btn-toggle bg-white/10 text-white" disabled>
|
||||||
<i class=\"fas fa-check\"></i>
|
<i class="fas fa-check"></i>
|
||||||
INSTALLED
|
${window.i18n ? window.i18n.t('mods.installed') : 'INSTALLED'}
|
||||||
</button>`
|
</button>`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -336,10 +366,11 @@ function createBrowseModCard(mod) {
|
|||||||
|
|
||||||
async function downloadAndInstallMod(modInfo) {
|
async function downloadAndInstallMod(modInfo) {
|
||||||
try {
|
try {
|
||||||
window.LauncherUI?.showProgress(`Downloading ${modInfo.name}...`);
|
const downloadMsg = window.i18n ? window.i18n.t('notifications.modsDownloading').replace('{name}', modInfo.name) : `Downloading ${modInfo.name}...`;
|
||||||
|
window.LauncherUI?.showProgress(downloadMsg);
|
||||||
|
|
||||||
const result = await window.electronAPI?.downloadMod(modInfo);
|
const result = await window.electronAPI?.downloadMod(modInfo);
|
||||||
|
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
const newMod = {
|
const newMod = {
|
||||||
id: result.modInfo.id,
|
id: result.modInfo.id,
|
||||||
@@ -354,30 +385,33 @@ async function downloadAndInstallMod(modInfo) {
|
|||||||
curseForgeId: modInfo.modId,
|
curseForgeId: modInfo.modId,
|
||||||
curseForgeFileId: modInfo.fileId
|
curseForgeFileId: modInfo.fileId
|
||||||
};
|
};
|
||||||
|
|
||||||
installedMods.push(newMod);
|
installedMods.push(newMod);
|
||||||
|
|
||||||
await loadInstalledMods();
|
await loadInstalledMods();
|
||||||
await loadBrowseMods();
|
await loadBrowseMods();
|
||||||
window.LauncherUI?.hideProgress();
|
window.LauncherUI?.hideProgress();
|
||||||
showNotification(`${modInfo.name} installed successfully! 🎉`, 'success');
|
const successMsg = window.i18n ? window.i18n.t('notifications.modsInstalledSuccess').replace('{name}', modInfo.name) : `${modInfo.name} installed successfully! 🎉`;
|
||||||
|
showNotification(successMsg, 'success');
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result?.error || 'Failed to download mod');
|
throw new Error(result?.error || 'Failed to download mod');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading mod:', error);
|
console.error('Error downloading mod:', error);
|
||||||
window.LauncherUI?.hideProgress();
|
window.LauncherUI?.hideProgress();
|
||||||
showNotification('Failed to download mod: ' + error.message, 'error');
|
const errorMsg = window.i18n ? window.i18n.t('notifications.modsDownloadFailed').replace('{error}', error.message) : 'Failed to download mod: ' + error.message;
|
||||||
|
showNotification(errorMsg, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleMod(modId) {
|
async function toggleMod(modId) {
|
||||||
try {
|
try {
|
||||||
window.LauncherUI?.showProgress('Toggling mod...');
|
const toggleMsg = window.i18n ? window.i18n.t('notifications.modsTogglingMod') : 'Toggling mod...';
|
||||||
|
window.LauncherUI?.showProgress(toggleMsg);
|
||||||
|
|
||||||
const modsPath = await window.electronAPI?.getModsPath();
|
const modsPath = await window.electronAPI?.getModsPath();
|
||||||
const result = await window.electronAPI?.toggleMod(modId, modsPath);
|
const result = await window.electronAPI?.toggleMod(modId, modsPath);
|
||||||
|
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
await loadInstalledMods();
|
await loadInstalledMods();
|
||||||
window.LauncherUI?.hideProgress();
|
window.LauncherUI?.hideProgress();
|
||||||
@@ -387,7 +421,8 @@ async function toggleMod(modId) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling mod:', error);
|
console.error('Error toggling mod:', error);
|
||||||
window.LauncherUI?.hideProgress();
|
window.LauncherUI?.hideProgress();
|
||||||
showNotification('Failed to toggle mod: ' + error.message, 'error');
|
const errorMsg = window.i18n ? window.i18n.t('notifications.modsToggleFailed').replace('{error}', error.message) : 'Failed to toggle mod: ' + error.message;
|
||||||
|
showNotification(errorMsg, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,27 +430,34 @@ async function deleteMod(modId) {
|
|||||||
const mod = installedMods.find(m => m.id === modId);
|
const mod = installedMods.find(m => m.id === modId);
|
||||||
if (!mod) return;
|
if (!mod) return;
|
||||||
|
|
||||||
|
const confirmMsg = window.i18n ?
|
||||||
|
window.i18n.t('mods.confirmDelete').replace('{name}', mod.name) + ' ' + window.i18n.t('mods.confirmDeleteDesc') :
|
||||||
|
`Are you sure you want to delete "${mod.name}"? This action cannot be undone.`;
|
||||||
|
|
||||||
showConfirmModal(
|
showConfirmModal(
|
||||||
`Are you sure you want to delete "${mod.name}"? This action cannot be undone.`,
|
confirmMsg,
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
window.LauncherUI?.showProgress('Deleting mod...');
|
const deleteMsg = window.i18n ? window.i18n.t('notifications.modsDeletingMod') : 'Deleting mod...';
|
||||||
|
window.LauncherUI?.showProgress(deleteMsg);
|
||||||
|
|
||||||
const modsPath = await window.electronAPI?.getModsPath();
|
const modsPath = await window.electronAPI?.getModsPath();
|
||||||
const result = await window.electronAPI?.uninstallMod(modId, modsPath);
|
const result = await window.electronAPI?.uninstallMod(modId, modsPath);
|
||||||
|
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
await loadInstalledMods();
|
await loadInstalledMods();
|
||||||
await loadBrowseMods();
|
await loadBrowseMods();
|
||||||
window.LauncherUI?.hideProgress();
|
window.LauncherUI?.hideProgress();
|
||||||
showNotification(`"${mod.name}" deleted successfully`, 'success');
|
const successMsg = window.i18n ? window.i18n.t('notifications.modsDeletedSuccess').replace('{name}', mod.name) : `"${mod.name}" deleted successfully`;
|
||||||
|
showNotification(successMsg, 'success');
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result?.error || 'Failed to delete mod');
|
throw new Error(result?.error || 'Failed to delete mod');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting mod:', error);
|
console.error('Error deleting mod:', error);
|
||||||
window.LauncherUI?.hideProgress();
|
window.LauncherUI?.hideProgress();
|
||||||
showNotification('Failed to delete mod: ' + error.message, 'error');
|
const errorMsg = window.i18n ? window.i18n.t('notifications.modsDeleteFailed').replace('{error}', error.message) : 'Failed to delete mod: ' + error.message;
|
||||||
|
showNotification(errorMsg, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -436,21 +478,21 @@ function showNotification(message, type = 'info', duration = 4000) {
|
|||||||
|
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = `mod-notification ${type}`;
|
notification.className = `mod-notification ${type}`;
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
success: 'fa-check-circle',
|
success: 'fa-check-circle',
|
||||||
error: 'fa-exclamation-circle',
|
error: 'fa-exclamation-circle',
|
||||||
info: 'fa-info-circle',
|
info: 'fa-info-circle',
|
||||||
warning: 'fa-exclamation-triangle'
|
warning: 'fa-exclamation-triangle'
|
||||||
};
|
};
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
success: '#10b981',
|
success: '#10b981',
|
||||||
error: '#ef4444',
|
error: '#ef4444',
|
||||||
info: '#3b82f6',
|
info: '#3b82f6',
|
||||||
warning: '#f59e0b'
|
warning: '#f59e0b'
|
||||||
};
|
};
|
||||||
|
|
||||||
notification.innerHTML = `
|
notification.innerHTML = `
|
||||||
<div class="notification-content">
|
<div class="notification-content">
|
||||||
<i class="fas ${icons[type]}"></i>
|
<i class="fas ${icons[type]}"></i>
|
||||||
@@ -460,7 +502,7 @@ function showNotification(message, type = 'info', duration = 4000) {
|
|||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
notification.style.cssText = `
|
notification.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
@@ -481,14 +523,14 @@ function showNotification(message, type = 'info', duration = 4000) {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const contentStyle = `
|
const contentStyle = `
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const closeStyle = `
|
const closeStyle = `
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -500,17 +542,17 @@ function showNotification(message, type = 'info', duration = 4000) {
|
|||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
notification.querySelector('.notification-content').style.cssText = contentStyle;
|
notification.querySelector('.notification-content').style.cssText = contentStyle;
|
||||||
notification.querySelector('.notification-close').style.cssText = closeStyle;
|
notification.querySelector('.notification-close').style.cssText = closeStyle;
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
// Animate in
|
// Animate in
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
notification.style.transform = 'translateX(0)';
|
notification.style.transform = 'translateX(0)';
|
||||||
}, 10);
|
}, 10);
|
||||||
|
|
||||||
// Auto remove
|
// Auto remove
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (notification.parentElement) {
|
if (notification.parentElement) {
|
||||||
@@ -522,7 +564,6 @@ function showNotification(message, type = 'info', duration = 4000) {
|
|||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom confirmation modal
|
|
||||||
function showConfirmModal(message, onConfirm, onCancel = null) {
|
function showConfirmModal(message, onConfirm, onCancel = null) {
|
||||||
const existingModal = document.querySelector('.mod-confirm-modal');
|
const existingModal = document.querySelector('.mod-confirm-modal');
|
||||||
if (existingModal) {
|
if (existingModal) {
|
||||||
@@ -565,7 +606,7 @@ function showConfirmModal(message, onConfirm, onCancel = null) {
|
|||||||
<div style="padding: 24px; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
<div style="padding: 24px; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||||
<div style="display: flex; align-items: center; gap: 12px; color: #ef4444;">
|
<div style="display: flex; align-items: center; gap: 12px; color: #ef4444;">
|
||||||
<i class="fas fa-exclamation-triangle" style="font-size: 24px;"></i>
|
<i class="fas fa-exclamation-triangle" style="font-size: 24px;"></i>
|
||||||
<h3 style="margin: 0; font-size: 1.2rem; font-weight: 600;">Confirm Deletion</h3>
|
<h3 style="margin: 0; font-size: 1.2rem; font-weight: 600;">${window.i18n ? window.i18n.t('mods.confirmDeletion') : 'Confirm Deletion'}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding: 24px; color: #e5e7eb;">
|
<div style="padding: 24px; color: #e5e7eb;">
|
||||||
@@ -581,7 +622,7 @@ function showConfirmModal(message, onConfirm, onCancel = null) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
">Cancel</button>
|
">${window.i18n ? window.i18n.t('common.cancel') : 'Cancel'}</button>
|
||||||
<button class="mod-confirm-delete" style="
|
<button class="mod-confirm-delete" style="
|
||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -591,7 +632,7 @@ function showConfirmModal(message, onConfirm, onCancel = null) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
">Delete</button>
|
">${window.i18n ? window.i18n.t('common.delete') : 'Delete'}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -652,13 +693,13 @@ function updatePagination() {
|
|||||||
|
|
||||||
if (currentPageEl) currentPageEl.textContent = modsPage + 1;
|
if (currentPageEl) currentPageEl.textContent = modsPage + 1;
|
||||||
if (totalPagesEl) totalPagesEl.textContent = modsTotalPages;
|
if (totalPagesEl) totalPagesEl.textContent = modsTotalPages;
|
||||||
|
|
||||||
if (prevBtn) {
|
if (prevBtn) {
|
||||||
prevBtn.disabled = modsPage === 0;
|
prevBtn.disabled = modsPage === 0;
|
||||||
prevBtn.style.opacity = modsPage === 0 ? '0.5' : '1';
|
prevBtn.style.opacity = modsPage === 0 ? '0.5' : '1';
|
||||||
prevBtn.style.cursor = modsPage === 0 ? 'not-allowed' : 'pointer';
|
prevBtn.style.cursor = modsPage === 0 ? 'not-allowed' : 'pointer';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextBtn) {
|
if (nextBtn) {
|
||||||
nextBtn.disabled = modsPage >= modsTotalPages - 1;
|
nextBtn.disabled = modsPage >= modsTotalPages - 1;
|
||||||
nextBtn.style.opacity = modsPage >= modsTotalPages - 1 ? '0.5' : '1';
|
nextBtn.style.opacity = modsPage >= modsTotalPages - 1 ? '0.5' : '1';
|
||||||
@@ -682,7 +723,7 @@ function showInstalledModsError(message) {
|
|||||||
function viewModPage(modId) {
|
function viewModPage(modId) {
|
||||||
console.log('Looking for mod with ID:', modId, 'Type:', typeof modId);
|
console.log('Looking for mod with ID:', modId, 'Type:', typeof modId);
|
||||||
console.log('Available mods:', browseMods.map(m => ({ id: m.id, name: m.name, type: typeof m.id })));
|
console.log('Available mods:', browseMods.map(m => ({ id: m.id, name: m.name, type: typeof m.id })));
|
||||||
|
|
||||||
const mod = browseMods.find(m => m.id.toString() === modId.toString());
|
const mod = browseMods.find(m => m.id.toString() === modId.toString());
|
||||||
if (mod) {
|
if (mod) {
|
||||||
console.log('Found mod:', mod.name);
|
console.log('Found mod:', mod.name);
|
||||||
@@ -695,9 +736,9 @@ function viewModPage(modId) {
|
|||||||
const nameSlug = mod.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
const nameSlug = mod.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
modUrl = `https://www.curseforge.com/hytale/mods/${nameSlug}`;
|
modUrl = `https://www.curseforge.com/hytale/mods/${nameSlug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Opening URL:', modUrl);
|
console.log('Opening URL:', modUrl);
|
||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.openExternalLink) {
|
if (window.electronAPI && window.electronAPI.openExternalLink) {
|
||||||
window.electronAPI.openExternalLink(modUrl);
|
window.electronAPI.openExternalLink(modUrl);
|
||||||
} else {
|
} else {
|
||||||
@@ -709,7 +750,8 @@ function viewModPage(modId) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Mod not found with ID:', modId);
|
console.error('Mod not found with ID:', modId);
|
||||||
showNotification('Mod information not found', 'error');
|
const errorMsg = window.i18n ? window.i18n.t('notifications.modsModNotFound') : 'Mod information not found';
|
||||||
|
showNotification(errorMsg, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,7 +760,9 @@ window.modsManager = {
|
|||||||
deleteMod,
|
deleteMod,
|
||||||
openMyModsModal,
|
openMyModsModal,
|
||||||
closeMyModsModal,
|
closeMyModsModal,
|
||||||
viewModPage
|
viewModPage,
|
||||||
|
loadInstalledMods,
|
||||||
|
loadBrowseMods
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initModsManager);
|
document.addEventListener('DOMContentLoaded', initModsManager);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
const API_URL = 'http://3.10.208.30/api';
|
const API_URL = 'https://api.hytalef2p.com/api';
|
||||||
let updateInterval = null;
|
let updateInterval = null;
|
||||||
let currentUserId = null;
|
let currentUserId = null;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,70 @@ import './mods.js';
|
|||||||
import './players.js';
|
import './players.js';
|
||||||
import './chat.js';
|
import './chat.js';
|
||||||
import './settings.js';
|
import './settings.js';
|
||||||
|
import './logs.js';
|
||||||
|
|
||||||
|
// Initialize i18n immediately (before DOMContentLoaded)
|
||||||
|
let i18nInitialized = false;
|
||||||
|
(async () => {
|
||||||
|
const savedLang = await window.electronAPI?.loadLanguage();
|
||||||
|
await i18n.init(savedLang);
|
||||||
|
i18nInitialized = true;
|
||||||
|
|
||||||
|
// Update language selector if DOM is already loaded
|
||||||
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||||
|
updateLanguageSelector();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function updateLanguageSelector() {
|
||||||
|
const langSelect = document.getElementById('languageSelect');
|
||||||
|
if (langSelect) {
|
||||||
|
// Clear existing options
|
||||||
|
langSelect.innerHTML = '';
|
||||||
|
|
||||||
|
const languages = i18n.getAvailableLanguages();
|
||||||
|
const currentLang = i18n.getCurrentLanguage();
|
||||||
|
|
||||||
|
languages.forEach(lang => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = lang.code;
|
||||||
|
option.textContent = lang.name;
|
||||||
|
if (lang.code === currentLang) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
langSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle language change (add listener only once)
|
||||||
|
if (!langSelect.hasAttribute('data-listener-added')) {
|
||||||
|
langSelect.addEventListener('change', async (e) => {
|
||||||
|
await i18n.setLanguage(e.target.value);
|
||||||
|
});
|
||||||
|
langSelect.setAttribute('data-listener-added', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Populate language selector (wait for i18n if needed)
|
||||||
|
if (i18nInitialized) {
|
||||||
|
updateLanguageSelector();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord notification
|
||||||
|
const notification = document.getElementById('discordNotification');
|
||||||
|
if (notification) {
|
||||||
|
const dismissed = localStorage.getItem('discordNotificationDismissed');
|
||||||
|
if (!dismissed) {
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.style.display = 'flex';
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
notification.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Discord notification functions
|
|
||||||
window.closeDiscordNotification = function() {
|
window.closeDiscordNotification = function() {
|
||||||
const notification = document.getElementById('discordNotification');
|
const notification = document.getElementById('discordNotification');
|
||||||
if (notification) {
|
if (notification) {
|
||||||
@@ -16,27 +78,5 @@ window.closeDiscordNotification = function() {
|
|||||||
notification.style.display = 'none';
|
notification.style.display = 'none';
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Show notification after a delay
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const notification = document.getElementById('discordNotification');
|
|
||||||
if (notification) {
|
|
||||||
// Check if user has previously dismissed the notification
|
|
||||||
const dismissed = localStorage.getItem('discordNotificationDismissed');
|
|
||||||
if (!dismissed) {
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.style.display = 'flex';
|
|
||||||
}, 3000); // Show after 3 seconds
|
|
||||||
} else {
|
|
||||||
notification.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remember when user closes notification
|
|
||||||
const originalClose = window.closeDiscordNotification;
|
|
||||||
window.closeDiscordNotification = function() {
|
|
||||||
localStorage.setItem('discordNotificationDismissed', 'true');
|
localStorage.setItem('discordNotificationDismissed', 'true');
|
||||||
originalClose();
|
|
||||||
};
|
};
|
||||||
1011
GUI/js/settings.js
1011
GUI/js/settings.js
File diff suppressed because it is too large
Load Diff
718
GUI/js/ui.js
718
GUI/js/ui.js
@@ -6,6 +6,24 @@ let progressText;
|
|||||||
let progressPercent;
|
let progressPercent;
|
||||||
let progressSpeed;
|
let progressSpeed;
|
||||||
let progressSize;
|
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) {
|
function showPage(pageId) {
|
||||||
const pages = document.querySelectorAll('.page');
|
const pages = document.querySelectorAll('.page');
|
||||||
@@ -13,6 +31,15 @@ function showPage(pageId) {
|
|||||||
if (page.id === pageId) {
|
if (page.id === pageId) {
|
||||||
page.classList.add('active');
|
page.classList.add('active');
|
||||||
page.style.display = '';
|
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 {
|
} else {
|
||||||
page.classList.remove('active');
|
page.classList.remove('active');
|
||||||
page.style.display = 'none';
|
page.style.display = 'none';
|
||||||
@@ -45,22 +72,32 @@ function handleNavigation() {
|
|||||||
function setupWindowControls() {
|
function setupWindowControls() {
|
||||||
const minimizeBtn = document.querySelector('.window-controls .minimize');
|
const minimizeBtn = document.querySelector('.window-controls .minimize');
|
||||||
const closeBtn = document.querySelector('.window-controls .close');
|
const closeBtn = document.querySelector('.window-controls .close');
|
||||||
|
|
||||||
const windowControls = document.querySelector('.window-controls');
|
const windowControls = document.querySelector('.window-controls');
|
||||||
const header = document.querySelector('.header');
|
const header = document.querySelector('.header');
|
||||||
|
|
||||||
|
const profileSelector = document.querySelector('.profile-selector');
|
||||||
|
|
||||||
|
if (profileSelector) {
|
||||||
|
profileSelector.style.pointerEvents = 'auto';
|
||||||
|
profileSelector.style.zIndex = '10000';
|
||||||
|
}
|
||||||
|
|
||||||
if (windowControls) {
|
if (windowControls) {
|
||||||
windowControls.style.pointerEvents = 'auto';
|
windowControls.style.pointerEvents = 'auto';
|
||||||
windowControls.style.zIndex = '10000';
|
windowControls.style.zIndex = '10000';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (header) {
|
if (header) {
|
||||||
header.style.webkitAppRegion = 'drag';
|
header.style.webkitAppRegion = 'drag';
|
||||||
if (windowControls) {
|
if (windowControls) {
|
||||||
windowControls.style.webkitAppRegion = 'no-drag';
|
windowControls.style.webkitAppRegion = 'no-drag';
|
||||||
}
|
}
|
||||||
|
if (profileSelector) {
|
||||||
|
profileSelector.style.webkitAppRegion = 'no-drag';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.electronAPI) {
|
if (window.electronAPI) {
|
||||||
if (minimizeBtn) {
|
if (minimizeBtn) {
|
||||||
minimizeBtn.onclick = (e) => {
|
minimizeBtn.onclick = (e) => {
|
||||||
@@ -82,7 +119,7 @@ function showLauncherOrInstall(isInstalled) {
|
|||||||
const install = document.getElementById('install-page');
|
const install = document.getElementById('install-page');
|
||||||
const sidebar = document.querySelector('.sidebar');
|
const sidebar = document.querySelector('.sidebar');
|
||||||
const gameTitle = document.querySelector('.game-title-section');
|
const gameTitle = document.querySelector('.game-title-section');
|
||||||
|
|
||||||
if (isInstalled) {
|
if (isInstalled) {
|
||||||
if (launcher) launcher.style.display = '';
|
if (launcher) launcher.style.display = '';
|
||||||
if (install) install.style.display = 'none';
|
if (install) install.style.display = 'none';
|
||||||
@@ -134,17 +171,23 @@ function hideProgress() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateProgress(data) {
|
function updateProgress(data) {
|
||||||
|
// Handle retry state
|
||||||
|
if (data.retryState) {
|
||||||
|
currentDownloadState.retryData = data.retryState;
|
||||||
|
updateRetryState(data.retryState);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.message && progressText) {
|
if (data.message && progressText) {
|
||||||
progressText.textContent = data.message;
|
progressText.textContent = data.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.percent !== null && data.percent !== undefined) {
|
if (data.percent !== null && data.percent !== undefined) {
|
||||||
const percent = Math.min(100, Math.max(0, Math.round(data.percent)));
|
const percent = Math.min(100, Math.max(0, Math.round(data.percent)));
|
||||||
if (progressPercent) progressPercent.textContent = `${percent}%`;
|
if (progressPercent) progressPercent.textContent = `${percent}%`;
|
||||||
if (progressBarFill) progressBarFill.style.width = `${percent}%`;
|
if (progressBarFill) progressBarFill.style.width = `${percent}%`;
|
||||||
if (progressBar) progressBar.style.width = `${percent}%`;
|
if (progressBar) progressBar.style.width = `${percent}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.speed && data.downloaded && data.total) {
|
if (data.speed && data.downloaded && data.total) {
|
||||||
const speedMB = (data.speed / 1024 / 1024).toFixed(2);
|
const speedMB = (data.speed / 1024 / 1024).toFixed(2);
|
||||||
const downloadedMB = (data.downloaded / 1024 / 1024).toFixed(2);
|
const downloadedMB = (data.downloaded / 1024 / 1024).toFixed(2);
|
||||||
@@ -152,18 +195,132 @@ function updateProgress(data) {
|
|||||||
if (progressSpeed) progressSpeed.textContent = `${speedMB} MB/s`;
|
if (progressSpeed) progressSpeed.textContent = `${speedMB} MB/s`;
|
||||||
if (progressSize) progressSize.textContent = `${downloadedMB} / ${totalMB} MB`;
|
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() {
|
function setupAnimations() {
|
||||||
document.body.style.opacity = '0';
|
document.body.style.opacity = '0';
|
||||||
document.body.style.transform = 'translateY(20px)';
|
document.body.style.transform = 'translateY(20px)';
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.body.style.transition = 'all 0.6s ease';
|
document.body.style.transition = 'all 0.6s ease';
|
||||||
document.body.style.opacity = '1';
|
document.body.style.opacity = '1';
|
||||||
document.body.style.transform = 'translateY(0)';
|
document.body.style.transform = 'translateY(0)';
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeInUp {
|
||||||
@@ -182,7 +339,7 @@ function setupAnimations() {
|
|||||||
|
|
||||||
function setupFirstLaunchHandlers() {
|
function setupFirstLaunchHandlers() {
|
||||||
console.log('Setting up first launch handlers...');
|
console.log('Setting up first launch handlers...');
|
||||||
|
|
||||||
window.electronAPI.onFirstLaunchUpdate((data) => {
|
window.electronAPI.onFirstLaunchUpdate((data) => {
|
||||||
console.log('Received first launch update event:', data);
|
console.log('Received first launch update event:', data);
|
||||||
showFirstLaunchUpdateDialog(data);
|
showFirstLaunchUpdateDialog(data);
|
||||||
@@ -195,12 +352,12 @@ function setupFirstLaunchHandlers() {
|
|||||||
showProgress();
|
showProgress();
|
||||||
updateProgress(data);
|
updateProgress(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
let lockButtonTimeout = null;
|
let lockButtonTimeout = null;
|
||||||
|
|
||||||
window.electronAPI.onLockPlayButton((locked) => {
|
window.electronAPI.onLockPlayButton((locked) => {
|
||||||
lockPlayButton(locked);
|
lockPlayButton(locked);
|
||||||
|
|
||||||
if (locked) {
|
if (locked) {
|
||||||
if (lockButtonTimeout) {
|
if (lockButtonTimeout) {
|
||||||
clearTimeout(lockButtonTimeout);
|
clearTimeout(lockButtonTimeout);
|
||||||
@@ -221,12 +378,12 @@ function setupFirstLaunchHandlers() {
|
|||||||
|
|
||||||
function showFirstLaunchUpdateDialog(data) {
|
function showFirstLaunchUpdateDialog(data) {
|
||||||
console.log('Creating first launch modal...');
|
console.log('Creating first launch modal...');
|
||||||
|
|
||||||
const existingModal = document.querySelector('.first-launch-modal-overlay');
|
const existingModal = document.querySelector('.first-launch-modal-overlay');
|
||||||
if (existingModal) {
|
if (existingModal) {
|
||||||
existingModal.remove();
|
existingModal.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
const modalOverlay = document.createElement('div');
|
const modalOverlay = document.createElement('div');
|
||||||
modalOverlay.className = 'first-launch-modal-overlay';
|
modalOverlay.className = 'first-launch-modal-overlay';
|
||||||
modalOverlay.style.cssText = `
|
modalOverlay.style.cssText = `
|
||||||
@@ -243,7 +400,7 @@ function showFirstLaunchUpdateDialog(data) {
|
|||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
pointer-events: all !important;
|
pointer-events: all !important;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const modalDialog = document.createElement('div');
|
const modalDialog = document.createElement('div');
|
||||||
modalDialog.className = 'first-launch-modal-dialog';
|
modalDialog.className = 'first-launch-modal-dialog';
|
||||||
modalDialog.style.cssText = `
|
modalDialog.style.cssText = `
|
||||||
@@ -257,7 +414,7 @@ function showFirstLaunchUpdateDialog(data) {
|
|||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
animation: modalSlideIn 0.3s ease-out !important;
|
animation: modalSlideIn 0.3s ease-out !important;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
modalDialog.innerHTML = `
|
modalDialog.innerHTML = `
|
||||||
<div style="background: linear-gradient(135deg, rgba(147, 51, 234, 0.2), rgba(59, 130, 246, 0.2)); padding: 25px; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
<div style="background: linear-gradient(135deg, rgba(147, 51, 234, 0.2), rgba(59, 130, 246, 0.2)); padding: 25px; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||||
<h2 style="margin: 0; color: #fff; font-size: 1.5rem; font-weight: 600; text-align: center;">
|
<h2 style="margin: 0; color: #fff; font-size: 1.5rem; font-weight: 600; text-align: center;">
|
||||||
@@ -306,9 +463,9 @@ function showFirstLaunchUpdateDialog(data) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
modalOverlay.appendChild(modalDialog);
|
modalOverlay.appendChild(modalDialog);
|
||||||
|
|
||||||
modalOverlay.onclick = (e) => {
|
modalOverlay.onclick = (e) => {
|
||||||
if (e.target === modalOverlay) {
|
if (e.target === modalOverlay) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -316,7 +473,7 @@ function showFirstLaunchUpdateDialog(data) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', function preventEscape(e) {
|
document.addEventListener('keydown', function preventEscape(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -324,55 +481,55 @@ function showFirstLaunchUpdateDialog(data) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.appendChild(modalOverlay);
|
document.body.appendChild(modalOverlay);
|
||||||
|
|
||||||
const updateBtn = document.getElementById('updateGameBtn');
|
const updateBtn = document.getElementById('updateGameBtn');
|
||||||
updateBtn.onclick = () => {
|
updateBtn.onclick = () => {
|
||||||
acceptFirstLaunchUpdate();
|
acceptFirstLaunchUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.firstLaunchExistingGame = data.existingGame;
|
window.firstLaunchExistingGame = data.existingGame;
|
||||||
|
|
||||||
console.log('First launch modal created and displayed');
|
console.log('First launch modal created and displayed');
|
||||||
}
|
}
|
||||||
|
|
||||||
function lockPlayButton(locked) {
|
function lockPlayButton(locked) {
|
||||||
const playButton = document.getElementById('homePlayBtn');
|
const playButton = document.getElementById('homePlayBtn');
|
||||||
|
|
||||||
if (!playButton) {
|
if (!playButton) {
|
||||||
console.warn('Play button not found');
|
console.warn('Play button not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locked) {
|
if (locked) {
|
||||||
playButton.style.opacity = '0.5';
|
playButton.style.opacity = '0.5';
|
||||||
playButton.style.pointerEvents = 'none';
|
playButton.style.pointerEvents = 'none';
|
||||||
playButton.style.cursor = 'not-allowed';
|
playButton.style.cursor = 'not-allowed';
|
||||||
playButton.setAttribute('data-locked', 'true');
|
playButton.setAttribute('data-locked', 'true');
|
||||||
|
|
||||||
const spanElement = playButton.querySelector('span');
|
const spanElement = playButton.querySelector('span');
|
||||||
if (spanElement) {
|
if (spanElement) {
|
||||||
if (!playButton.getAttribute('data-original-text')) {
|
if (!playButton.getAttribute('data-original-text')) {
|
||||||
playButton.setAttribute('data-original-text', spanElement.textContent);
|
playButton.setAttribute('data-original-text', spanElement.textContent);
|
||||||
}
|
}
|
||||||
spanElement.textContent = 'CHECKING...';
|
spanElement.textContent = window.i18n ? window.i18n.t('play.checking') : 'CHECKING...';
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Play button locked');
|
console.log('Play button locked');
|
||||||
} else {
|
} else {
|
||||||
playButton.style.opacity = '';
|
playButton.style.opacity = '';
|
||||||
playButton.style.pointerEvents = '';
|
playButton.style.pointerEvents = '';
|
||||||
playButton.style.cursor = '';
|
playButton.style.cursor = '';
|
||||||
playButton.removeAttribute('data-locked');
|
playButton.removeAttribute('data-locked');
|
||||||
|
|
||||||
const spanElement = playButton.querySelector('span');
|
const spanElement = playButton.querySelector('span');
|
||||||
const originalText = playButton.getAttribute('data-original-text');
|
if (spanElement) {
|
||||||
if (spanElement && originalText) {
|
// Use i18n to get the current translation instead of restoring saved text
|
||||||
spanElement.textContent = originalText;
|
spanElement.textContent = window.i18n ? window.i18n.t('play.playButton') : 'PLAY HYTALE';
|
||||||
playButton.removeAttribute('data-original-text');
|
playButton.removeAttribute('data-original-text');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Play button unlocked');
|
console.log('Play button unlocked');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,12 +538,13 @@ function lockPlayButton(locked) {
|
|||||||
|
|
||||||
async function acceptFirstLaunchUpdate() {
|
async function acceptFirstLaunchUpdate() {
|
||||||
const existingGame = window.firstLaunchExistingGame;
|
const existingGame = window.firstLaunchExistingGame;
|
||||||
|
|
||||||
if (!existingGame) {
|
if (!existingGame) {
|
||||||
showNotification('Error: Game data not found', 'error');
|
const errorMsg = window.i18n ? window.i18n.t('notifications.gameDataNotFound') : 'Error: Game data not found';
|
||||||
|
showNotification(errorMsg, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modal = document.querySelector('.first-launch-modal-overlay');
|
const modal = document.querySelector('.first-launch-modal-overlay');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.style.pointerEvents = 'none';
|
modal.style.pointerEvents = 'none';
|
||||||
@@ -397,27 +555,30 @@ async function acceptFirstLaunchUpdate() {
|
|||||||
btn.textContent = '🔄 Updating...';
|
btn.textContent = '🔄 Updating...';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showProgress();
|
showProgress();
|
||||||
updateProgress({ message: 'Starting mandatory game update...', percent: 0 });
|
const updateMsg = window.i18n ? window.i18n.t('progress.startingUpdate') : 'Starting mandatory game update...';
|
||||||
|
updateProgress({ message: updateMsg, percent: 0 });
|
||||||
|
|
||||||
const result = await window.electronAPI.acceptFirstLaunchUpdate(existingGame);
|
const result = await window.electronAPI.acceptFirstLaunchUpdate(existingGame);
|
||||||
|
|
||||||
window.electronAPI.markAsLaunched && window.electronAPI.markAsLaunched();
|
window.electronAPI.markAsLaunched && window.electronAPI.markAsLaunched();
|
||||||
|
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.remove();
|
modal.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
lockPlayButton(false);
|
lockPlayButton(false);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
hideProgress();
|
hideProgress();
|
||||||
showNotification('Game updated successfully! 🎉', 'success');
|
const successMsg = window.i18n ? window.i18n.t('notifications.gameUpdatedSuccess') : 'Game updated successfully! 🎉';
|
||||||
|
showNotification(successMsg, 'success');
|
||||||
} else {
|
} else {
|
||||||
hideProgress();
|
hideProgress();
|
||||||
showNotification(`Update failed: ${result.error}`, 'error');
|
const errorMsg = window.i18n ? window.i18n.t('notifications.updateFailed').replace('{error}', result.error) : `Update failed: ${result.error}`;
|
||||||
|
showNotification(errorMsg, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
@@ -425,7 +586,8 @@ async function acceptFirstLaunchUpdate() {
|
|||||||
}
|
}
|
||||||
lockPlayButton(false);
|
lockPlayButton(false);
|
||||||
hideProgress();
|
hideProgress();
|
||||||
showNotification(`Update error: ${error.message}`, 'error');
|
const errorMsg = window.i18n ? window.i18n.t('notifications.updateError').replace('{error}', error.message) : `Update error: ${error.message}`;
|
||||||
|
showNotification(errorMsg, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,7 +596,7 @@ function dismissFirstLaunchDialog() {
|
|||||||
if (modal) {
|
if (modal) {
|
||||||
modal.remove();
|
modal.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
lockPlayButton(false);
|
lockPlayButton(false);
|
||||||
window.electronAPI.markAsLaunched && window.electronAPI.markAsLaunched();
|
window.electronAPI.markAsLaunched && window.electronAPI.markAsLaunched();
|
||||||
}
|
}
|
||||||
@@ -443,13 +605,13 @@ function showNotification(message, type = 'info') {
|
|||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = `notification notification-${type}`;
|
notification.className = `notification notification-${type}`;
|
||||||
notification.textContent = message;
|
notification.textContent = message;
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
notification.classList.add('show');
|
notification.classList.add('show');
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
notification.remove();
|
notification.remove();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@@ -463,9 +625,21 @@ function setupUI() {
|
|||||||
progressPercent = document.getElementById('progressPercent');
|
progressPercent = document.getElementById('progressPercent');
|
||||||
progressSpeed = document.getElementById('progressSpeed');
|
progressSpeed = document.getElementById('progressSpeed');
|
||||||
progressSize = document.getElementById('progressSize');
|
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);
|
lockPlayButton(true);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const playButton = document.getElementById('homePlayBtn');
|
const playButton = document.getElementById('homePlayBtn');
|
||||||
if (playButton && playButton.getAttribute('data-locked') === 'true') {
|
if (playButton && playButton.getAttribute('data-locked') === 'true') {
|
||||||
@@ -476,16 +650,83 @@ function setupUI() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 25000);
|
}, 25000);
|
||||||
|
|
||||||
handleNavigation();
|
handleNavigation();
|
||||||
setupWindowControls();
|
setupWindowControls();
|
||||||
setupSidebarLogo();
|
setupSidebarLogo();
|
||||||
setupAnimations();
|
setupAnimations();
|
||||||
setupFirstLaunchHandlers();
|
setupFirstLaunchHandlers();
|
||||||
|
loadLauncherVersion();
|
||||||
|
checkGameInstallation().catch(err => {
|
||||||
|
console.error('Critical error in checkGameInstallation:', err);
|
||||||
|
lockPlayButton(false);
|
||||||
|
});
|
||||||
|
|
||||||
document.body.focus();
|
document.body.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load launcher version from package.json
|
||||||
|
async function loadLauncherVersion() {
|
||||||
|
try {
|
||||||
|
if (window.electronAPI && window.electronAPI.getVersion) {
|
||||||
|
const version = await window.electronAPI.getVersion();
|
||||||
|
const versionElement = document.getElementById('launcherVersion');
|
||||||
|
if (versionElement) {
|
||||||
|
versionElement.textContent = `v${version}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load launcher version:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = {
|
window.LauncherUI = {
|
||||||
showPage,
|
showPage,
|
||||||
setActiveNav,
|
setActiveNav,
|
||||||
@@ -495,4 +736,371 @@ window.LauncherUI = {
|
|||||||
updateProgress
|
updateProgress
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Make installation effects globally available
|
||||||
|
|
||||||
|
|
||||||
|
// Draggable progress bar functionality
|
||||||
|
function setupProgressDrag() {
|
||||||
|
if (!progressOverlay) return;
|
||||||
|
|
||||||
|
let isDragging = false;
|
||||||
|
let offsetX;
|
||||||
|
let offsetY;
|
||||||
|
|
||||||
|
progressOverlay.addEventListener('mousedown', dragStart);
|
||||||
|
document.addEventListener('mousemove', drag);
|
||||||
|
document.addEventListener('mouseup', dragEnd);
|
||||||
|
|
||||||
|
function dragStart(e) {
|
||||||
|
// Only drag if clicking on the overlay itself, not on buttons or inputs
|
||||||
|
if (e.target.closest('.progress-bar-fill')) return;
|
||||||
|
|
||||||
|
if (e.target === progressOverlay || e.target.closest('.progress-content')) {
|
||||||
|
isDragging = true;
|
||||||
|
progressOverlay.classList.add('dragging');
|
||||||
|
|
||||||
|
// Get the current position of the progress overlay
|
||||||
|
const rect = progressOverlay.getBoundingClientRect();
|
||||||
|
offsetX = e.clientX - rect.left - progressOverlay.offsetWidth / 2;
|
||||||
|
offsetY = e.clientY - rect.top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag(e) {
|
||||||
|
if (isDragging) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Calculate new position
|
||||||
|
const newX = e.clientX - offsetX - progressOverlay.offsetWidth / 2;
|
||||||
|
const newY = e.clientY - offsetY;
|
||||||
|
|
||||||
|
// Get window bounds
|
||||||
|
const maxX = window.innerWidth - progressOverlay.offsetWidth;
|
||||||
|
const maxY = window.innerHeight - progressOverlay.offsetHeight;
|
||||||
|
const minX = 0;
|
||||||
|
const minY = 0;
|
||||||
|
|
||||||
|
// Constrain to window bounds
|
||||||
|
const constrainedX = Math.max(minX, Math.min(newX, maxX));
|
||||||
|
const constrainedY = Math.max(minY, Math.min(newY, maxY));
|
||||||
|
|
||||||
|
progressOverlay.style.left = constrainedX + 'px';
|
||||||
|
progressOverlay.style.bottom = 'auto';
|
||||||
|
progressOverlay.style.top = constrainedY + 'px';
|
||||||
|
progressOverlay.style.transform = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragEnd() {
|
||||||
|
isDragging = false;
|
||||||
|
progressOverlay.classList.remove('dragging');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle maximize/restore window function
|
||||||
|
function toggleMaximize() {
|
||||||
|
if (window.electronAPI && window.electronAPI.maximizeWindow) {
|
||||||
|
window.electronAPI.maximizeWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', setupUI);
|
document.addEventListener('DOMContentLoaded', setupUI);
|
||||||
|
|||||||
216
GUI/js/update.js
216
GUI/js/update.js
@@ -10,6 +10,23 @@ class ClientUpdateManager {
|
|||||||
this.showUpdatePopup(updateInfo);
|
this.showUpdatePopup(updateInfo);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for electron-updater events
|
||||||
|
window.electronAPI.onUpdateAvailable((updateInfo) => {
|
||||||
|
this.showUpdatePopup(updateInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electronAPI.onUpdateDownloadProgress((progress) => {
|
||||||
|
this.updateDownloadProgress(progress);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electronAPI.onUpdateDownloaded((updateInfo) => {
|
||||||
|
this.showUpdateDownloaded(updateInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electronAPI.onUpdateError((errorInfo) => {
|
||||||
|
this.handleUpdateError(errorInfo);
|
||||||
|
});
|
||||||
|
|
||||||
this.checkForUpdatesOnDemand();
|
this.checkForUpdatesOnDemand();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,23 +50,46 @@ class ClientUpdateManager {
|
|||||||
<div class="update-popup-versions">
|
<div class="update-popup-versions">
|
||||||
<div class="version-row">
|
<div class="version-row">
|
||||||
<span class="version-label">Current Version:</span>
|
<span class="version-label">Current Version:</span>
|
||||||
<span class="version-current">${updateInfo.currentVersion}</span>
|
<span class="version-current">${updateInfo.currentVersion || updateInfo.version || 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="version-row">
|
<div class="version-row">
|
||||||
<span class="version-label">New Version:</span>
|
<span class="version-label">New Version:</span>
|
||||||
<span class="version-new">${updateInfo.newVersion}</span>
|
<span class="version-new">${updateInfo.newVersion || updateInfo.version || 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="update-popup-message">
|
<div class="update-popup-message">
|
||||||
A new version of Hytale F2P Launcher is available.<br>
|
A new version of Hytale F2P Launcher is available.<br>
|
||||||
Please download the latest version to continue using the launcher.
|
<span id="update-status-text">Downloading update automatically...</span>
|
||||||
|
<div id="update-error-message" style="display: none; margin-top: 0.75rem; padding: 0.75rem; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 0.5rem; color: #fca5a5; font-size: 0.875rem;">
|
||||||
|
<i class="fas fa-exclamation-triangle" style="margin-right: 0.5rem;"></i>
|
||||||
|
<span id="update-error-text"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="update-download-btn" class="update-download-btn">
|
<div id="update-progress-container" style="display: none; margin-bottom: 1rem;">
|
||||||
<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>
|
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.75rem; color: #9ca3af;">
|
||||||
Download Update
|
<span id="update-progress-percent">0%</span>
|
||||||
</button>
|
<span id="update-progress-speed">0 KB/s</span>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100%; height: 8px; background: rgba(255, 255, 255, 0.1); border-radius: 4px; overflow: hidden;">
|
||||||
|
<div id="update-progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #3b82f6, #9333ea); transition: width 0.3s ease;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 0.5rem; font-size: 0.75rem; color: #9ca3af; text-align: center;">
|
||||||
|
<span id="update-progress-size">0 MB / 0 MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="update-buttons-container" style="display: none;">
|
||||||
|
<button id="update-install-btn" class="update-download-btn">
|
||||||
|
<i class="fas fa-check" style="margin-right: 0.5rem;"></i>
|
||||||
|
Install & Restart
|
||||||
|
</button>
|
||||||
|
<button id="update-download-btn" class="update-download-btn update-download-btn-secondary" style="margin-top: 0.75rem;">
|
||||||
|
<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>
|
||||||
|
Manually Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="update-popup-footer">
|
<div class="update-popup-footer">
|
||||||
This popup cannot be closed until you update the launcher
|
This popup cannot be closed until you update the launcher
|
||||||
@@ -62,6 +102,31 @@ class ClientUpdateManager {
|
|||||||
|
|
||||||
this.blockInterface();
|
this.blockInterface();
|
||||||
|
|
||||||
|
// Show progress container immediately (auto-download is enabled)
|
||||||
|
const progressContainer = document.getElementById('update-progress-container');
|
||||||
|
if (progressContainer) {
|
||||||
|
progressContainer.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
const installBtn = document.getElementById('update-install-btn');
|
||||||
|
if (installBtn) {
|
||||||
|
installBtn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
installBtn.disabled = true;
|
||||||
|
installBtn.innerHTML = '<i class="fas fa-spinner fa-spin" style="margin-right: 0.5rem;"></i>Installing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.electronAPI.quitAndInstallUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error installing update:', error);
|
||||||
|
installBtn.disabled = false;
|
||||||
|
installBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Install & Restart';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const downloadBtn = document.getElementById('update-download-btn');
|
const downloadBtn = document.getElementById('update-download-btn');
|
||||||
if (downloadBtn) {
|
if (downloadBtn) {
|
||||||
downloadBtn.addEventListener('click', async (e) => {
|
downloadBtn.addEventListener('click', async (e) => {
|
||||||
@@ -80,7 +145,7 @@ class ClientUpdateManager {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error opening download page:', error);
|
console.error('❌ Error opening download page:', error);
|
||||||
downloadBtn.disabled = false;
|
downloadBtn.disabled = false;
|
||||||
downloadBtn.innerHTML = '<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>Download Update';
|
downloadBtn.innerHTML = '<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>Manually Download';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -99,6 +164,134 @@ class ClientUpdateManager {
|
|||||||
console.log('🔔 Update popup displayed with new style');
|
console.log('🔔 Update popup displayed with new style');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateDownloadProgress(progress) {
|
||||||
|
const progressBar = document.getElementById('update-progress-bar');
|
||||||
|
const progressPercent = document.getElementById('update-progress-percent');
|
||||||
|
const progressSpeed = document.getElementById('update-progress-speed');
|
||||||
|
const progressSize = document.getElementById('update-progress-size');
|
||||||
|
|
||||||
|
if (progressBar && progress) {
|
||||||
|
const percent = Math.round(progress.percent || 0);
|
||||||
|
progressBar.style.width = `${percent}%`;
|
||||||
|
|
||||||
|
if (progressPercent) {
|
||||||
|
progressPercent.textContent = `${percent}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressSpeed && progress.bytesPerSecond) {
|
||||||
|
const speedMBps = (progress.bytesPerSecond / 1024 / 1024).toFixed(2);
|
||||||
|
progressSpeed.textContent = `${speedMBps} MB/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressSize && progress.transferred && progress.total) {
|
||||||
|
const transferredMB = (progress.transferred / 1024 / 1024).toFixed(2);
|
||||||
|
const totalMB = (progress.total / 1024 / 1024).toFixed(2);
|
||||||
|
progressSize.textContent = `${transferredMB} MB / ${totalMB} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't update status text here - it's already set and the progress bar shows the percentage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showUpdateDownloaded(updateInfo) {
|
||||||
|
const statusText = document.getElementById('update-status-text');
|
||||||
|
const progressContainer = document.getElementById('update-progress-container');
|
||||||
|
const buttonsContainer = document.getElementById('update-buttons-container');
|
||||||
|
|
||||||
|
if (statusText) {
|
||||||
|
statusText.textContent = 'Update downloaded! Ready to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressContainer) {
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buttonsContainer) {
|
||||||
|
buttonsContainer.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Update downloaded, ready to install');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateError(errorInfo) {
|
||||||
|
console.error('Update error:', errorInfo);
|
||||||
|
|
||||||
|
// If manual download is required, update the UI (this will handle status text)
|
||||||
|
if (errorInfo.requiresManualDownload) {
|
||||||
|
this.showManualDownloadRequired(errorInfo);
|
||||||
|
return; // Don't do anything else, showManualDownloadRequired handles everything
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-critical errors, just show error message without changing status
|
||||||
|
const errorMessage = document.getElementById('update-error-message');
|
||||||
|
const errorText = document.getElementById('update-error-text');
|
||||||
|
|
||||||
|
if (errorMessage && errorText) {
|
||||||
|
let message = errorInfo.message || 'An error occurred during the update process.';
|
||||||
|
if (errorInfo.isMacSigningError) {
|
||||||
|
message = 'Auto-update requires code signing. Please download manually.';
|
||||||
|
}
|
||||||
|
errorText.textContent = message;
|
||||||
|
errorMessage.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showManualDownloadRequired(errorInfo) {
|
||||||
|
const statusText = document.getElementById('update-status-text');
|
||||||
|
const progressContainer = document.getElementById('update-progress-container');
|
||||||
|
const buttonsContainer = document.getElementById('update-buttons-container');
|
||||||
|
const installBtn = document.getElementById('update-install-btn');
|
||||||
|
const downloadBtn = document.getElementById('update-download-btn');
|
||||||
|
const errorMessage = document.getElementById('update-error-message');
|
||||||
|
const errorText = document.getElementById('update-error-text');
|
||||||
|
|
||||||
|
// Hide progress and install button
|
||||||
|
if (progressContainer) {
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installBtn) {
|
||||||
|
installBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status message (only once, don't change it again)
|
||||||
|
if (statusText && !statusText.dataset.manualMode) {
|
||||||
|
statusText.textContent = 'Please download and install the update manually.';
|
||||||
|
statusText.dataset.manualMode = 'true'; // Mark that we've set manual mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error message with details
|
||||||
|
if (errorMessage && errorText) {
|
||||||
|
let message = 'Auto-update is not available. ';
|
||||||
|
if (errorInfo.isMacSigningError) {
|
||||||
|
message = 'This app requires code signing for automatic updates.';
|
||||||
|
} else if (errorInfo.isLinuxInstallError) {
|
||||||
|
message = 'Auto-installation requires root privileges. Please download and install the update manually using your package manager.';
|
||||||
|
} else if (errorInfo.message) {
|
||||||
|
message = errorInfo.message;
|
||||||
|
} else {
|
||||||
|
message = 'An error occurred during the update process.';
|
||||||
|
}
|
||||||
|
errorText.textContent = message;
|
||||||
|
errorMessage.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show and enable the manual download button (make it primary since it's the only option)
|
||||||
|
if (downloadBtn) {
|
||||||
|
downloadBtn.style.display = 'block';
|
||||||
|
downloadBtn.disabled = false;
|
||||||
|
downloadBtn.classList.remove('update-download-btn-secondary');
|
||||||
|
downloadBtn.innerHTML = '<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>Download Update Manually';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show buttons container if not already visible
|
||||||
|
if (buttonsContainer) {
|
||||||
|
buttonsContainer.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('⚠️ Manual download required due to update error');
|
||||||
|
}
|
||||||
|
|
||||||
blockInterface() {
|
blockInterface() {
|
||||||
const mainContent = document.querySelector('.flex.w-full.h-screen');
|
const mainContent = document.querySelector('.flex.w-full.h-screen');
|
||||||
if (mainContent) {
|
if (mainContent) {
|
||||||
@@ -144,7 +337,12 @@ class ClientUpdateManager {
|
|||||||
async checkForUpdatesOnDemand() {
|
async checkForUpdatesOnDemand() {
|
||||||
try {
|
try {
|
||||||
const updateInfo = await window.electronAPI.checkForUpdates();
|
const updateInfo = await window.electronAPI.checkForUpdates();
|
||||||
if (updateInfo.updateAvailable) {
|
|
||||||
|
// Double-check that versions are actually different before showing popup
|
||||||
|
if (updateInfo.updateAvailable &&
|
||||||
|
updateInfo.newVersion &&
|
||||||
|
updateInfo.currentVersion &&
|
||||||
|
updateInfo.newVersion !== updateInfo.currentVersion) {
|
||||||
this.showUpdatePopup(updateInfo);
|
this.showUpdatePopup(updateInfo);
|
||||||
}
|
}
|
||||||
return updateInfo;
|
return updateInfo;
|
||||||
|
|||||||
149
GUI/js/updater.js
Normal file
149
GUI/js/updater.js
Normal 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
|
||||||
|
};
|
||||||
283
GUI/locales/de.json
Normal file
283
GUI/locales/de.json
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"play": "Spielen",
|
||||||
|
"mods": "Mods",
|
||||||
|
"news": "Neuigkeiten",
|
||||||
|
"chat": "Spieler-Chat",
|
||||||
|
"settings": "Einstellungen"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"playersLabel": "Spieler:",
|
||||||
|
"manageProfiles": "Profile verwalten",
|
||||||
|
"defaultProfile": "Standard"
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"title": "KOSTENLOSER LAUNCHER",
|
||||||
|
"playerName": "Spielername",
|
||||||
|
"playerNamePlaceholder": "Namen eingeben",
|
||||||
|
"gameBranch": "Spielversion",
|
||||||
|
"releaseVersion": "Release (Stabil)",
|
||||||
|
"preReleaseVersion": "Pre-Release (Experimentell)",
|
||||||
|
"customInstallation": "Benutzerdefinierte Installation",
|
||||||
|
"installationFolder": "Installationsordner",
|
||||||
|
"pathPlaceholder": "Standardspeicherort",
|
||||||
|
"browse": "Durchsuchen",
|
||||||
|
"installButton": "HYTALE INSTALLIEREN",
|
||||||
|
"installing": "INSTALLIERE..."
|
||||||
|
},
|
||||||
|
"play": {
|
||||||
|
"ready": "BEREIT ZUM SPIELEN",
|
||||||
|
"subtitle": "Starte Hytale und beginne das Abenteuer",
|
||||||
|
"playButton": "HYTALE SPIELEN",
|
||||||
|
"latestNews": "NEUESTE NACHRICHTEN",
|
||||||
|
"viewAll": "ALLE ANZEIGEN",
|
||||||
|
"checking": "ÜBERPRÜFE...",
|
||||||
|
"play": "SPIELEN"
|
||||||
|
},
|
||||||
|
"mods": {
|
||||||
|
"searchPlaceholder": "Mods suchen...",
|
||||||
|
"myMods": "MEINE MODS",
|
||||||
|
"previous": "ZURÜCK",
|
||||||
|
"next": "WEITER",
|
||||||
|
"page": "Seite",
|
||||||
|
"of": "von",
|
||||||
|
"modalTitle": "MEINE MODS",
|
||||||
|
"noModsFound": "Keine Mods gefunden",
|
||||||
|
"noModsFoundDesc": "Versuche deine Suche anzupassen",
|
||||||
|
"noModsInstalled": "Keine Mods installiert",
|
||||||
|
"noModsInstalledDesc": "Füge Mods von CurseForge hinzu oder importiere lokale Dateien",
|
||||||
|
"view": "ANZEIGEN",
|
||||||
|
"install": "INSTALLIEREN",
|
||||||
|
"installed": "INSTALLIERT",
|
||||||
|
"enable": "AKTIVIEREN",
|
||||||
|
"disable": "DEAKTIVIEREN",
|
||||||
|
"active": "AKTIV",
|
||||||
|
"disabled": "DEAKTIVIERT",
|
||||||
|
"delete": "Mod löschen",
|
||||||
|
"noDescription": "Keine Beschreibung verfügbar",
|
||||||
|
"confirmDelete": "Möchtest du \"{name}\" wirklich löschen?",
|
||||||
|
"confirmDeleteDesc": "Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"confirmDeletion": "Löschung bestätigen",
|
||||||
|
"apiKeyRequired": "API-Schlüssel erforderlich",
|
||||||
|
"apiKeyRequiredDesc": "CurseForge API-Schlüssel wird benötigt, um Mods zu durchsuchen"
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "ALLE NACHRICHTEN",
|
||||||
|
"readMore": "Mehr lesen"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "SPIELER-CHAT",
|
||||||
|
"pickColor": "Farbe",
|
||||||
|
"inputPlaceholder": "Nachricht eingeben...",
|
||||||
|
"send": "Senden",
|
||||||
|
"online": "online",
|
||||||
|
"charCounter": "{current}/{max}",
|
||||||
|
"secureChat": "Sicherer Chat - Links werden zensiert",
|
||||||
|
"joinChat": "Chat beitreten",
|
||||||
|
"chooseUsername": "Wähle einen Benutzernamen, um dem Spieler-Chat beizutreten",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"usernamePlaceholder": "Benutzernamen eingeben...",
|
||||||
|
"usernameHint": "3-20 Zeichen, nur Buchstaben, Zahlen, - und _",
|
||||||
|
"joinButton": "Chat beitreten",
|
||||||
|
"colorModal": {
|
||||||
|
"title": "Benutzernamenfarbe anpassen",
|
||||||
|
"chooseSolid": "Wähle eine einfarbige Farbe:",
|
||||||
|
"customColor": "Benutzerdefinierte Farbe:",
|
||||||
|
"preview": "Vorschau:",
|
||||||
|
"previewUsername": "Benutzername",
|
||||||
|
"apply": "Farbe anwenden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "EINSTELLUNGEN",
|
||||||
|
"java": "Java Runtime",
|
||||||
|
"useCustomJava": "Benutzerdefinierten Java-Pfad verwenden",
|
||||||
|
"javaDescription": "Ersetze die mitgelieferte Java-Installation durch deine eigene",
|
||||||
|
"javaPath": "Java-Ausführungsdatei-Pfad",
|
||||||
|
"javaPathPlaceholder": "Java-Pfad auswählen...",
|
||||||
|
"javaBrowse": "Durchsuchen",
|
||||||
|
"javaHint": "Wähle den Java-Installationsordner (unterstützt Windows, Mac, Linux)",
|
||||||
|
"discord": "Discord-Integration",
|
||||||
|
"enableRPC": "Discord Rich Presence aktivieren",
|
||||||
|
"discordDescription": "Zeige deine Launcher-Aktivität auf Discord",
|
||||||
|
"game": "Spieloptionen",
|
||||||
|
"playerName": "Spielername",
|
||||||
|
"playerNamePlaceholder": "Spielernamen eingeben",
|
||||||
|
"playerNameHint": "Dieser Name wird im Spiel verwendet (1-16 Zeichen)",
|
||||||
|
"openGameLocation": "Spielordner öffnen",
|
||||||
|
"openGameLocationDesc": "Öffne den Spielinstallationsordner",
|
||||||
|
"account": "Spieler-UUID-Verwaltung",
|
||||||
|
"currentUUID": "Aktuelle UUID",
|
||||||
|
"uuidPlaceholder": "UUID wird geladen...",
|
||||||
|
"copyUUID": "UUID kopieren",
|
||||||
|
"regenerateUUID": "UUID neu generieren",
|
||||||
|
"uuidHint": "Deine eindeutige Spielerkennung für diesen Benutzernamen",
|
||||||
|
"manageUUIDs": "Alle UUIDs verwalten",
|
||||||
|
"manageUUIDsDesc": "Alle Spieler-UUIDs anzeigen und verwalten",
|
||||||
|
"language": "Sprache",
|
||||||
|
"selectLanguage": "Sprache auswählen",
|
||||||
|
"repairGame": "Spiel reparieren",
|
||||||
|
"reinstallGame": "Spieldateien neu installieren (behält Daten)",
|
||||||
|
"gpuPreference": "GPU-Präferenz",
|
||||||
|
"gpuHint": "Wähle deine bevorzugte GPU (Linux: betrifft DRI_PRIME)",
|
||||||
|
"gpuAuto": "Auto",
|
||||||
|
"gpuIntegrated": "Integriert",
|
||||||
|
"gpuDedicated": "Dediziert",
|
||||||
|
"logs": "SYSTEMPROTOKOLLE",
|
||||||
|
"logsCopy": "Kopieren",
|
||||||
|
"logsRefresh": "Aktualisieren",
|
||||||
|
"logsFolder": "Ordner öffnen",
|
||||||
|
"logsLoading": "Protokolle werden geladen...",
|
||||||
|
"closeLauncher": "Launcher-Verhalten",
|
||||||
|
"closeOnStart": "Launcher beim Spielstart schließen",
|
||||||
|
"closeOnStartDescription": "Schließe den Launcher automatisch, nachdem Hytale gestartet wurde",
|
||||||
|
"hwAccel": "Hardware-Beschleunigung",
|
||||||
|
"hwAccelDescription": "Hardware-Beschleunigung für den Launcher aktivieren",
|
||||||
|
"gameBranch": "Spiel-Branch",
|
||||||
|
"branchRelease": "Release",
|
||||||
|
"branchPreRelease": "Pre-Release",
|
||||||
|
"branchHint": "Wechsel zwischen stabiler Release- und experimenteller Pre-Release-Version",
|
||||||
|
"branchWarning": "Das Ändern des Branches lädt eine andere Spielversion herunter und installiert sie",
|
||||||
|
"branchSwitching": "Wechsle zu {branch}...",
|
||||||
|
"branchSwitched": "Erfolgreich zu {branch} gewechselt!",
|
||||||
|
"installRequired": "Installation erforderlich",
|
||||||
|
"branchInstallConfirm": "Das Spiel wird für den {branch}-Branch installiert. Fortfahren?"
|
||||||
|
},
|
||||||
|
"uuid": {
|
||||||
|
"modalTitle": "UUID-Verwaltung",
|
||||||
|
"currentUserUUID": "Aktuelle Benutzer-UUID",
|
||||||
|
"allPlayerUUIDs": "Alle Spieler-UUIDs",
|
||||||
|
"generateNew": "Neue UUID generieren",
|
||||||
|
"loadingUUIDs": "UUIDs werden geladen...",
|
||||||
|
"setCustomUUID": "Benutzerdefinierte UUID festlegen",
|
||||||
|
"customPlaceholder": "Benutzerdefinierte UUID eingeben (Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
|
||||||
|
"setUUID": "UUID festlegen",
|
||||||
|
"warning": "Warnung: Das Festlegen einer benutzerdefinierten UUID ändert deine aktuelle Spieleridentität",
|
||||||
|
"copyTooltip": "UUID kopieren",
|
||||||
|
"regenerateTooltip": "Neue UUID generieren"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"modalTitle": "Profile verwalten",
|
||||||
|
"newProfilePlaceholder": "Neuer Profilname",
|
||||||
|
"createProfile": "Profil erstellen"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"notificationText": "Tritt unserer Discord-Community bei!",
|
||||||
|
"joinButton": "Discord beitreten"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"confirm": "Bestätigen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"close": "Schließen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"loading": "Lädt...",
|
||||||
|
"apply": "Anwenden",
|
||||||
|
"install": "Installieren"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"gameDataNotFound": "Fehler: Spieldaten nicht gefunden",
|
||||||
|
"gameUpdatedSuccess": "Spiel erfolgreich aktualisiert! 🎉",
|
||||||
|
"updateFailed": "Update fehlgeschlagen: {error}",
|
||||||
|
"updateError": "Update-Fehler: {error}",
|
||||||
|
"discordEnabled": "Discord Rich Presence aktiviert",
|
||||||
|
"discordDisabled": "Discord Rich Presence deaktiviert",
|
||||||
|
"discordSaveFailed": "Discord-Einstellung konnte nicht gespeichert werden",
|
||||||
|
"playerNameRequired": "Bitte gib einen gültigen Spielernamen ein",
|
||||||
|
"playerNameSaved": "Spielername erfolgreich gespeichert",
|
||||||
|
"playerNameSaveFailed": "Spielername konnte nicht gespeichert werden",
|
||||||
|
"uuidCopied": "UUID in die Zwischenablage kopiert!",
|
||||||
|
"uuidCopyFailed": "UUID konnte nicht kopiert werden",
|
||||||
|
"uuidRegenNotAvailable": "UUID-Neugenerierung nicht verfügbar",
|
||||||
|
"uuidRegenFailed": "UUID konnte nicht neu generiert werden",
|
||||||
|
"uuidGenerated": "Neue UUID erfolgreich generiert!",
|
||||||
|
"uuidGeneratedShort": "Neue UUID generiert!",
|
||||||
|
"uuidGenerateFailed": "Neue UUID konnte nicht generiert werden",
|
||||||
|
"uuidRequired": "Bitte gib eine UUID ein",
|
||||||
|
"uuidInvalidFormat": "Ungültiges UUID-Format",
|
||||||
|
"uuidSetFailed": "Benutzerdefinierte UUID konnte nicht festgelegt werden",
|
||||||
|
"uuidSetSuccess": "Benutzerdefinierte UUID erfolgreich festgelegt!",
|
||||||
|
"uuidDeleteFailed": "UUID konnte nicht gelöscht werden",
|
||||||
|
"uuidDeleteSuccess": "UUID erfolgreich gelöscht!",
|
||||||
|
"modsDownloading": "{name} wird heruntergeladen...",
|
||||||
|
"modsTogglingMod": "Mod wird umgeschaltet...",
|
||||||
|
"modsDeletingMod": "Mod wird gelöscht...",
|
||||||
|
"modsLoadingMods": "Mods von CurseForge werden geladen...",
|
||||||
|
"modsInstalledSuccess": "{name} erfolgreich installiert! 🎉",
|
||||||
|
"modsDeletedSuccess": "{name} erfolgreich gelöscht",
|
||||||
|
"modsDownloadFailed": "Mod konnte nicht heruntergeladen werden: {error}",
|
||||||
|
"modsToggleFailed": "Mod konnte nicht umgeschaltet werden: {error}",
|
||||||
|
"modsDeleteFailed": "Mod konnte nicht gelöscht werden: {error}",
|
||||||
|
"modsModNotFound": "Mod-Informationen nicht gefunden",
|
||||||
|
"hwAccelSaved": "Hardware-Beschleunigungseinstellung gespeichert",
|
||||||
|
"hwAccelSaveFailed": "Hardware-Beschleunigungseinstellung konnte nicht gespeichert werden",
|
||||||
|
"javaPathCopied": "Java-Pfad in die Zwischenablage kopiert!",
|
||||||
|
"javaPathCopyFailed": "Java-Pfad konnte nicht kopiert werden",
|
||||||
|
"javaPathSaved": "Java-Pfad erfolgreich gespeichert!",
|
||||||
|
"javaPathSaveFailed": "Java-Pfad konnte nicht gespeichert werden",
|
||||||
|
"javaPathInvalid": "Ungültiger Java-Pfad",
|
||||||
|
"javaPathReset": "Java-Pfad auf Standardwerte zurückgesetzt",
|
||||||
|
"gameLocationError": "Spielordner konnte nicht geöffnet werden",
|
||||||
|
"launcherRestartRequired": "Launcher-Neustart erforderlich, um Änderungen anzuwenden",
|
||||||
|
"gameRepairConfirm": "Möchtest du das Spiel wirklich reparieren? Dies wird alle Spieldateien neu installieren.",
|
||||||
|
"gameRepairInProgress": "Spiel wird repariert...",
|
||||||
|
"gameRepairSuccess": "Spiel erfolgreich repariert!",
|
||||||
|
"gameRepairFailed": "Spielreparatur fehlgeschlagen: {error}",
|
||||||
|
"invalidUsername": "Ungültiger Benutzername",
|
||||||
|
"usernameInUse": "Benutzername bereits vergeben",
|
||||||
|
"chatJoinSuccess": "Du bist dem Chat beigetreten!",
|
||||||
|
"chatJoinFailed": "Chat-Beitritt fehlgeschlagen",
|
||||||
|
"messageTooLong": "Nachricht zu lang",
|
||||||
|
"messageSent": "Nachricht gesendet",
|
||||||
|
"messageSendFailed": "Nachricht konnte nicht gesendet werden",
|
||||||
|
"colorUpdated": "Farbe aktualisiert!",
|
||||||
|
"colorUpdateFailed": "Farbe konnte nicht aktualisiert werden",
|
||||||
|
"profileCreated": "Profil erfolgreich erstellt!",
|
||||||
|
"profileCreateFailed": "Profil konnte nicht erstellt werden",
|
||||||
|
"profileDeleted": "Profil gelöscht",
|
||||||
|
"profileDeleteFailed": "Profil konnte nicht gelöscht werden",
|
||||||
|
"profileSwitched": "Profil gewechselt zu: {name}",
|
||||||
|
"profileSwitchFailed": "Profilwechsel fehlgeschlagen",
|
||||||
|
"invalidProfileName": "Ungültiger Profilname",
|
||||||
|
"profileNameExists": "Ein Profil mit diesem Namen existiert bereits",
|
||||||
|
"noInternet": "Keine Internetverbindung",
|
||||||
|
"checkInternetConnection": "Überprüfe deine Internetverbindung",
|
||||||
|
"serverError": "Serverfehler. Bitte versuche es später erneut.",
|
||||||
|
"unknownError": "Ein unbekannter Fehler ist aufgetreten"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"defaultTitle": "Aktion bestätigen",
|
||||||
|
"regenerateUuidTitle": "Neue UUID generieren",
|
||||||
|
"regenerateUuidMessage": "Möchtest du wirklich eine neue UUID generieren? Dies ändert deine Spieleridentität.",
|
||||||
|
"regenerateUuidButton": "Generieren",
|
||||||
|
"setCustomUuidTitle": "Benutzerdefinierte UUID festlegen",
|
||||||
|
"setCustomUuidMessage": "Möchtest du wirklich diese benutzerdefinierte UUID festlegen? Dies ändert deine Spieleridentität.",
|
||||||
|
"setCustomUuidButton": "UUID festlegen",
|
||||||
|
"deleteUuidTitle": "UUID löschen",
|
||||||
|
"deleteUuidMessage": "Möchtest du wirklich die UUID für \"{username}\" löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"deleteUuidButton": "Löschen",
|
||||||
|
"uninstallGameTitle": "Spiel deinstallieren",
|
||||||
|
"uninstallGameMessage": "Möchtest du Hytale wirklich deinstallieren? Alle Spieldateien werden gelöscht.",
|
||||||
|
"uninstallGameButton": "Deinstallieren"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"initializing": "Initialisiere...",
|
||||||
|
"downloading": "Lädt herunter...",
|
||||||
|
"installing": "Installiere...",
|
||||||
|
"extracting": "Entpacke...",
|
||||||
|
"verifying": "Überprüfe...",
|
||||||
|
"switchingProfile": "Profil wird gewechselt...",
|
||||||
|
"profileSwitched": "Profil gewechselt!",
|
||||||
|
"startingGame": "Spiel wird gestartet...",
|
||||||
|
"launching": "STARTET...",
|
||||||
|
"uninstallingGame": "Spiel wird deinstalliert...",
|
||||||
|
"gameUninstalled": "Spiel erfolgreich deinstalliert!",
|
||||||
|
"uninstallFailed": "Deinstallation fehlgeschlagen: {error}",
|
||||||
|
"startingUpdate": "Obligatorisches Spiel-Update wird gestartet...",
|
||||||
|
"installationComplete": "Installation erfolgreich abgeschlossen!",
|
||||||
|
"installationFailed": "Installation fehlgeschlagen: {error}",
|
||||||
|
"installingGameFiles": "Spieldateien werden installiert...",
|
||||||
|
"installComplete": "Installation abgeschlossen!"
|
||||||
|
}
|
||||||
|
}
|
||||||
250
GUI/locales/en.json
Normal file
250
GUI/locales/en.json
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"play": "Play",
|
||||||
|
"mods": "Mods",
|
||||||
|
"news": "News",
|
||||||
|
"chat": "Players Chat",
|
||||||
|
"settings": "Settings"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"playersLabel": "Players:",
|
||||||
|
"manageProfiles": "Manage Profiles",
|
||||||
|
"defaultProfile": "Default"
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"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",
|
||||||
|
"browse": "Browse",
|
||||||
|
"installButton": "INSTALL HYTALE",
|
||||||
|
"installing": "INSTALLING..."
|
||||||
|
},
|
||||||
|
"play": {
|
||||||
|
"ready": "READY TO PLAY",
|
||||||
|
"subtitle": "Launch Hytale and enter the adventure",
|
||||||
|
"playButton": "PLAY HYTALE",
|
||||||
|
"latestNews": "LATEST NEWS",
|
||||||
|
"viewAll": "VIEW ALL",
|
||||||
|
"checking": "CHECKING...",
|
||||||
|
"play": "PLAY"
|
||||||
|
},
|
||||||
|
"mods": {
|
||||||
|
"searchPlaceholder": "Search mods...",
|
||||||
|
"myMods": "MY MODS",
|
||||||
|
"previous": "PREVIOUS",
|
||||||
|
"next": "NEXT",
|
||||||
|
"page": "Page",
|
||||||
|
"of": "of",
|
||||||
|
"modalTitle": "MY MODS",
|
||||||
|
"noModsFound": "No Mods Found",
|
||||||
|
"noModsFoundDesc": "Try adjusting your search",
|
||||||
|
"noModsInstalled": "No Mods Installed",
|
||||||
|
"noModsInstalledDesc": "Add mods from CurseForge or import local files",
|
||||||
|
"view": "VIEW",
|
||||||
|
"install": "INSTALL",
|
||||||
|
"installed": "INSTALLED",
|
||||||
|
"enable": "ENABLE",
|
||||||
|
"disable": "DISABLE",
|
||||||
|
"active": "ACTIVE",
|
||||||
|
"disabled": "DISABLED",
|
||||||
|
"delete": "Delete mod",
|
||||||
|
"noDescription": "No description available",
|
||||||
|
"confirmDelete": "Are you sure you want to delete \"{name}\"?",
|
||||||
|
"confirmDeleteDesc": "This action cannot be undone.",
|
||||||
|
"confirmDeletion": "Confirm Deletion",
|
||||||
|
"apiKeyRequired": "API Key Required",
|
||||||
|
"apiKeyRequiredDesc": "CurseForge API key is needed to browse mods"
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "ALL NEWS",
|
||||||
|
"readMore": "Read More"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "PLAYERS CHAT",
|
||||||
|
"pickColor": "Color",
|
||||||
|
"inputPlaceholder": "Type your message...",
|
||||||
|
"send": "Send",
|
||||||
|
"online": "online",
|
||||||
|
"charCounter": "{current}/{max}",
|
||||||
|
"secureChat": "Secure chat - Links are censored",
|
||||||
|
"joinChat": "Join Chat",
|
||||||
|
"chooseUsername": "Choose a username to join the Players Chat",
|
||||||
|
"username": "Username",
|
||||||
|
"usernamePlaceholder": "Enter your username...",
|
||||||
|
"usernameHint": "3-20 characters, letters, numbers, - and _ only",
|
||||||
|
"joinButton": "Join Chat",
|
||||||
|
"colorModal": {
|
||||||
|
"title": "Customize Username Color",
|
||||||
|
"chooseSolid": "Choose a solid color:",
|
||||||
|
"customColor": "Custom color:",
|
||||||
|
"preview": "Preview:",
|
||||||
|
"previewUsername": "Username",
|
||||||
|
"apply": "Apply Color"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "SETTINGS",
|
||||||
|
"java": "Java Runtime",
|
||||||
|
"useCustomJava": "Use Custom Java Path",
|
||||||
|
"javaDescription": "Override the bundled Java runtime with your own installation",
|
||||||
|
"javaPath": "Java Executable Path",
|
||||||
|
"javaPathPlaceholder": "Select Java path...",
|
||||||
|
"javaBrowse": "Browse",
|
||||||
|
"javaHint": "Select the Java installation folder (supports Windows, Mac, Linux)",
|
||||||
|
"discord": "Discord Integration",
|
||||||
|
"enableRPC": "Enable Discord Rich Presence",
|
||||||
|
"discordDescription": "Show your launcher activity on Discord",
|
||||||
|
"game": "Game Options",
|
||||||
|
"playerName": "Player Name",
|
||||||
|
"playerNamePlaceholder": "Enter your player name",
|
||||||
|
"playerNameHint": "This name will be used in-game (1-16 characters)",
|
||||||
|
"openGameLocation": "Open Game Location",
|
||||||
|
"openGameLocationDesc": "Open the game installation folder",
|
||||||
|
"account": "Player UUID Management",
|
||||||
|
"currentUUID": "Current UUID",
|
||||||
|
"uuidPlaceholder": "Loading UUID...",
|
||||||
|
"copyUUID": "Copy UUID",
|
||||||
|
"regenerateUUID": "Regenerate UUID",
|
||||||
|
"uuidHint": "Your unique player identifier for this username",
|
||||||
|
"manageUUIDs": "Manage All UUIDs",
|
||||||
|
"manageUUIDsDesc": "View and manage all player UUIDs",
|
||||||
|
"language": "Language",
|
||||||
|
"selectLanguage": "Select Language",
|
||||||
|
"repairGame": "Repair Game",
|
||||||
|
"reinstallGame": "Reinstall game files (preserves data)",
|
||||||
|
"gpuPreference": "GPU Preference",
|
||||||
|
"gpuHint": "Select your preferred GPU (Linux: affects DRI_PRIME)",
|
||||||
|
"gpuAuto": "Auto",
|
||||||
|
"gpuIntegrated": "Integrated",
|
||||||
|
"gpuDedicated": "Dedicated",
|
||||||
|
"logs": "SYSTEM LOGS",
|
||||||
|
"logsCopy": "Copy",
|
||||||
|
"logsRefresh": "Refresh",
|
||||||
|
"logsFolder": "Open Folder",
|
||||||
|
"logsLoading": "Loading logs...",
|
||||||
|
"closeLauncher": "Launcher Behavior",
|
||||||
|
"closeOnStart": "Close Launcher on game start",
|
||||||
|
"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",
|
||||||
|
"currentUserUUID": "Current User UUID",
|
||||||
|
"allPlayerUUIDs": "All Player UUIDs",
|
||||||
|
"generateNew": "Generate New UUID",
|
||||||
|
"loadingUUIDs": "Loading UUIDs...",
|
||||||
|
"setCustomUUID": "Set Custom UUID",
|
||||||
|
"customPlaceholder": "Enter custom UUID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
|
||||||
|
"setUUID": "Set UUID",
|
||||||
|
"warning": "Warning: Setting a custom UUID will change your current player identity",
|
||||||
|
"copyTooltip": "Copy UUID",
|
||||||
|
"regenerateTooltip": "Generate New UUID"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"modalTitle": "Manage Profiles",
|
||||||
|
"newProfilePlaceholder": "New Profile Name",
|
||||||
|
"createProfile": "Create Profile"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"notificationText": "Join our Discord community!",
|
||||||
|
"joinButton": "Join Discord"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"close": "Close",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"apply": "Apply",
|
||||||
|
"install": "Install"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"gameDataNotFound": "Error: Game data not found",
|
||||||
|
"gameUpdatedSuccess": "Game updated successfully! 🎉",
|
||||||
|
"updateFailed": "Update failed: {error}",
|
||||||
|
"updateError": "Update error: {error}",
|
||||||
|
"discordEnabled": "Discord Rich Presence enabled",
|
||||||
|
"discordDisabled": "Discord Rich Presence disabled",
|
||||||
|
"discordSaveFailed": "Failed to save Discord setting",
|
||||||
|
"playerNameRequired": "Please enter a valid player name",
|
||||||
|
"playerNameSaved": "Player name saved successfully",
|
||||||
|
"playerNameSaveFailed": "Failed to save player name",
|
||||||
|
"uuidCopied": "UUID copied to clipboard!",
|
||||||
|
"uuidCopyFailed": "Failed to copy UUID",
|
||||||
|
"uuidRegenNotAvailable": "UUID regeneration not available",
|
||||||
|
"uuidRegenFailed": "Failed to regenerate UUID",
|
||||||
|
"uuidGenerated": "New UUID generated successfully!",
|
||||||
|
"uuidGeneratedShort": "New UUID generated!",
|
||||||
|
"uuidGenerateFailed": "Failed to generate new UUID",
|
||||||
|
"uuidRequired": "Please enter a UUID",
|
||||||
|
"uuidInvalidFormat": "Invalid UUID format",
|
||||||
|
"uuidSetFailed": "Failed to set custom UUID",
|
||||||
|
"uuidSetSuccess": "Custom UUID set successfully!",
|
||||||
|
"uuidDeleteFailed": "Failed to delete UUID",
|
||||||
|
"uuidDeleteSuccess": "UUID deleted successfully!",
|
||||||
|
"modsDownloading": "Downloading {name}...",
|
||||||
|
"modsTogglingMod": "Toggling mod...",
|
||||||
|
"modsDeletingMod": "Deleting mod...",
|
||||||
|
"modsLoadingMods": "Loading mods from CurseForge...",
|
||||||
|
"modsInstalledSuccess": "{name} installed successfully! 🎉",
|
||||||
|
"modsDeletedSuccess": "{name} deleted successfully",
|
||||||
|
"modsDownloadFailed": "Failed to download mod: {error}",
|
||||||
|
"modsToggleFailed": "Failed to toggle mod: {error}",
|
||||||
|
"modsDeleteFailed": "Failed to delete mod: {error}",
|
||||||
|
"modsModNotFound": "Mod information not found",
|
||||||
|
"hwAccelSaved": "Hardware acceleration setting saved",
|
||||||
|
"hwAccelSaveFailed": "Failed to save hardware acceleration setting"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"defaultTitle": "Confirm action",
|
||||||
|
"regenerateUuidTitle": "Generate new UUID",
|
||||||
|
"regenerateUuidMessage": "Are you sure you want to generate a new UUID? This will change your player identity.",
|
||||||
|
"regenerateUuidButton": "Generate",
|
||||||
|
"setCustomUuidTitle": "Set custom UUID",
|
||||||
|
"setCustomUuidMessage": "Are you sure you want to set this custom UUID? This will change your player identity.",
|
||||||
|
"setCustomUuidButton": "Set UUID",
|
||||||
|
"deleteUuidTitle": "Delete UUID",
|
||||||
|
"deleteUuidMessage": "Are you sure you want to delete the UUID for \"{username}\"? This action cannot be undone.",
|
||||||
|
"deleteUuidButton": "Delete",
|
||||||
|
"uninstallGameTitle": "Uninstall game",
|
||||||
|
"uninstallGameMessage": "Are you sure you want to uninstall Hytale? All game files will be deleted.",
|
||||||
|
"uninstallGameButton": "Uninstall"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"initializing": "Initializing...",
|
||||||
|
"downloading": "Downloading...",
|
||||||
|
"installing": "Installing...",
|
||||||
|
"extracting": "Extracting...",
|
||||||
|
"verifying": "Verifying...",
|
||||||
|
"switchingProfile": "Switching profile...",
|
||||||
|
"profileSwitched": "Profile switched!",
|
||||||
|
"startingGame": "Starting game...",
|
||||||
|
"launching": "LAUNCHING...",
|
||||||
|
"uninstallingGame": "Uninstalling game...",
|
||||||
|
"gameUninstalled": "Game uninstalled successfully!",
|
||||||
|
"uninstallFailed": "Uninstall failed: {error}",
|
||||||
|
"startingUpdate": "Starting mandatory game update...",
|
||||||
|
"installationComplete": "Installation completed successfully!",
|
||||||
|
"installationFailed": "Installation failed: {error}",
|
||||||
|
"installingGameFiles": "Installing game files...",
|
||||||
|
"installComplete": "Installation complete!"
|
||||||
|
}
|
||||||
|
}
|
||||||
246
GUI/locales/es-ES.json
Normal file
246
GUI/locales/es-ES.json
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"play": "Jugar",
|
||||||
|
"mods": "Mods",
|
||||||
|
"news": "Noticias",
|
||||||
|
"chat": "Chat de Jugadores",
|
||||||
|
"settings": "Configuración"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"playersLabel": "Jugadores:",
|
||||||
|
"manageProfiles": "Gestionar Perfiles",
|
||||||
|
"defaultProfile": "Predeterminado"
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"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",
|
||||||
|
"browse": "Examinar",
|
||||||
|
"installButton": "INSTALAR HYTALE",
|
||||||
|
"installing": "INSTALANDO..."
|
||||||
|
},
|
||||||
|
"play": {
|
||||||
|
"ready": "LISTO PARA JUGAR",
|
||||||
|
"subtitle": "Inicia Hytale y entra en la aventura",
|
||||||
|
"playButton": "JUGAR HYTALE",
|
||||||
|
"latestNews": "ÚLTIMAS NOTICIAS",
|
||||||
|
"viewAll": "VER TODO",
|
||||||
|
"checking": "VERIFICANDO...",
|
||||||
|
"play": "JUGAR"
|
||||||
|
},
|
||||||
|
"mods": {
|
||||||
|
"searchPlaceholder": "Buscar mods...",
|
||||||
|
"myMods": "MIS MODS",
|
||||||
|
"previous": "ANTERIOR",
|
||||||
|
"next": "SIGUIENTE",
|
||||||
|
"page": "Página",
|
||||||
|
"of": "de",
|
||||||
|
"modalTitle": "MIS MODS",
|
||||||
|
"noModsFound": "No se encontraron mods",
|
||||||
|
"noModsFoundDesc": "Intenta ajustar tu búsqueda",
|
||||||
|
"noModsInstalled": "No hay mods instalados",
|
||||||
|
"noModsInstalledDesc": "Añade mods desde CurseForge o importa archivos locales",
|
||||||
|
"view": "VER",
|
||||||
|
"install": "INSTALAR",
|
||||||
|
"installed": "INSTALADO",
|
||||||
|
"enable": "ACTIVAR",
|
||||||
|
"disable": "DESACTIVAR",
|
||||||
|
"active": "ACTIVO",
|
||||||
|
"disabled": "DESACTIVADO",
|
||||||
|
"delete": "Eliminar mod",
|
||||||
|
"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",
|
||||||
|
"apiKeyRequired": "Clave API Requerida",
|
||||||
|
"apiKeyRequiredDesc": "Se necesita una clave API de CurseForge para explorar mods"
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "TODAS LAS NOTICIAS",
|
||||||
|
"readMore": "Leer más"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "CHAT DE JUGADORES",
|
||||||
|
"pickColor": "Color",
|
||||||
|
"inputPlaceholder": "Escribe tu mensaje...",
|
||||||
|
"send": "Enviar",
|
||||||
|
"online": "en línea",
|
||||||
|
"charCounter": "{current}/{max}",
|
||||||
|
"secureChat": "Chat seguro - Los enlaces están censurados",
|
||||||
|
"joinChat": "Unirse al chat",
|
||||||
|
"chooseUsername": "Elige un nombre de usuario para unirte al chat de jugadores",
|
||||||
|
"username": "Nombre de usuario",
|
||||||
|
"usernamePlaceholder": "Ingresa tu nombre de usuario...",
|
||||||
|
"usernameHint": "3-20 caracteres, letras, números, - y _ solamente",
|
||||||
|
"joinButton": "Unirse al Chat",
|
||||||
|
"colorModal": {
|
||||||
|
"title": "Personalizar color del nombre",
|
||||||
|
"chooseSolid": "Elige un color sólido:",
|
||||||
|
"customColor": "Color personalizado:",
|
||||||
|
"preview": "Vista previa:",
|
||||||
|
"previewUsername": "Nombre de usuario",
|
||||||
|
"apply": "Aplicar color"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "CONFIGURACIÓN",
|
||||||
|
"java": "Entorno Java",
|
||||||
|
"useCustomJava": "Usar ruta de Java personalizada",
|
||||||
|
"javaDescription": "Reemplaza el entorno Java incluido con tu propia instalación",
|
||||||
|
"javaPath": "Ruta del ejecutable Java",
|
||||||
|
"javaPathPlaceholder": "Selecciona la ruta de Java...",
|
||||||
|
"javaBrowse": "Examinar",
|
||||||
|
"javaHint": "Selecciona la carpeta de instalación de Java (compatible con Windows, Mac, Linux)",
|
||||||
|
"discord": "Integración con Discord",
|
||||||
|
"enableRPC": "Habilitar Discord Rich Presence",
|
||||||
|
"discordDescription": "Muestra tu actividad del launcher en Discord",
|
||||||
|
"game": "Opciones del juego",
|
||||||
|
"playerName": "Nombre del jugador",
|
||||||
|
"playerNamePlaceholder": "Ingresa tu nombre de jugador",
|
||||||
|
"playerNameHint": "Este nombre se usará en el juego (1-16 caracteres)",
|
||||||
|
"openGameLocation": "Abrir ubicación del juego",
|
||||||
|
"openGameLocationDesc": "Abre la carpeta de instalación del juego",
|
||||||
|
"account": "Gestión de UUID del jugador",
|
||||||
|
"currentUUID": "UUID actual",
|
||||||
|
"uuidPlaceholder": "Cargando UUID...",
|
||||||
|
"copyUUID": "Copiar UUID",
|
||||||
|
"regenerateUUID": "Regenerar UUID",
|
||||||
|
"uuidHint": "Tu identificador único de jugador para este nombre de usuario",
|
||||||
|
"manageUUIDs": "Gestionar todos los UUIDs",
|
||||||
|
"manageUUIDsDesc": "Ver y gestionar todos los UUIDs de jugadores",
|
||||||
|
"language": "Idioma",
|
||||||
|
"selectLanguage": "Seleccionar idioma",
|
||||||
|
"repairGame": "Reparar juego",
|
||||||
|
"reinstallGame": "Reinstalar archivos del juego (conserva los datos)",
|
||||||
|
"gpuPreference": "Preferencia de GPU",
|
||||||
|
"gpuHint": "Selecciona tu GPU preferida (Linux: afecta DRI_PRIME)",
|
||||||
|
"gpuAuto": "Automático",
|
||||||
|
"gpuIntegrated": "Integrada",
|
||||||
|
"gpuDedicated": "Dedicada",
|
||||||
|
"logs": "REGISTROS DEL SISTEMA",
|
||||||
|
"logsCopy": "Copiar",
|
||||||
|
"logsRefresh": "Actualizar",
|
||||||
|
"logsFolder": "Abrir Carpeta",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"currentUserUUID": "UUID del usuario actual",
|
||||||
|
"allPlayerUUIDs": "Todos los UUIDs de jugadores",
|
||||||
|
"generateNew": "Generar nuevo UUID",
|
||||||
|
"loadingUUIDs": "Cargando UUIDs...",
|
||||||
|
"setCustomUUID": "Establecer UUID personalizado",
|
||||||
|
"customPlaceholder": "Ingresa un UUID personalizado (formato: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
|
||||||
|
"setUUID": "Establecer UUID",
|
||||||
|
"warning": "Advertencia: Establecer un UUID personalizado cambiará tu identidad de jugador actual",
|
||||||
|
"copyTooltip": "Copiar UUID",
|
||||||
|
"regenerateTooltip": "Generar nuevo UUID"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"modalTitle": "Gestionar perfiles",
|
||||||
|
"newProfilePlaceholder": "Nombre del nuevo perfil",
|
||||||
|
"createProfile": "Crear perfil"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"notificationText": "¡Únete a nuestra comunidad de Discord!",
|
||||||
|
"joinButton": "Unirse a Discord"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"confirm": "Confirmar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"save": "Guardar",
|
||||||
|
"close": "Cerrar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"edit": "Editar",
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"apply": "Aplicar",
|
||||||
|
"install": "Instalar"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"gameDataNotFound": "Error: No se encontraron datos del juego",
|
||||||
|
"gameUpdatedSuccess": "¡Juego actualizado con éxito! 🎉",
|
||||||
|
"updateFailed": "Actualización fallida: {error}",
|
||||||
|
"updateError": "Error de actualización: {error}",
|
||||||
|
"discordEnabled": "Discord Rich Presence habilitado",
|
||||||
|
"discordDisabled": "Discord Rich Presence deshabilitado",
|
||||||
|
"discordSaveFailed": "Error al guardar la configuración de Discord",
|
||||||
|
"playerNameRequired": "Por favor ingresa un nombre de jugador válido",
|
||||||
|
"playerNameSaved": "Nombre de jugador guardado con éxito",
|
||||||
|
"playerNameSaveFailed": "Error al guardar el nombre de jugador",
|
||||||
|
"uuidCopied": "¡UUID copiado al portapapeles!",
|
||||||
|
"uuidCopyFailed": "Error al copiar UUID",
|
||||||
|
"uuidRegenNotAvailable": "Regeneración de UUID no disponible",
|
||||||
|
"uuidRegenFailed": "Error al regenerar UUID",
|
||||||
|
"uuidGenerated": "¡Nuevo UUID generado con éxito!",
|
||||||
|
"uuidGeneratedShort": "¡Nuevo UUID generado!",
|
||||||
|
"uuidGenerateFailed": "Error al generar nuevo UUID",
|
||||||
|
"uuidRequired": "Por favor ingresa un UUID",
|
||||||
|
"uuidInvalidFormat": "Formato de UUID inválido",
|
||||||
|
"uuidSetFailed": "Error al establecer UUID personalizado",
|
||||||
|
"uuidSetSuccess": "¡UUID personalizado establecido con éxito!",
|
||||||
|
"uuidDeleteFailed": "Error al eliminar UUID",
|
||||||
|
"uuidDeleteSuccess": "¡UUID eliminado con éxito!",
|
||||||
|
"modsDownloading": "Descargando {name}...",
|
||||||
|
"modsTogglingMod": "Alternando mod...",
|
||||||
|
"modsDeletingMod": "Eliminando mod...",
|
||||||
|
"modsLoadingMods": "Cargando mods desde CurseForge...",
|
||||||
|
"modsInstalledSuccess": "¡{name} instalado con éxito! 🎉",
|
||||||
|
"modsDeletedSuccess": "{name} eliminado con éxito",
|
||||||
|
"modsDownloadFailed": "Error al descargar mod: {error}",
|
||||||
|
"modsToggleFailed": "Error al alternar mod: {error}",
|
||||||
|
"modsDeleteFailed": "Error al eliminar mod: {error}",
|
||||||
|
"modsModNotFound": "Información del mod no encontrada"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"defaultTitle": "Confirmar acción",
|
||||||
|
"regenerateUuidTitle": "Generar nuevo UUID",
|
||||||
|
"regenerateUuidMessage": "¿Estás seguro de que quieres generar un nuevo UUID? Esto cambiará tu identidad de jugador.",
|
||||||
|
"regenerateUuidButton": "Generar",
|
||||||
|
"setCustomUuidTitle": "Establecer UUID personalizado",
|
||||||
|
"setCustomUuidMessage": "¿Estás seguro de que quieres establecer este UUID personalizado? Esto cambiará tu identidad de jugador.",
|
||||||
|
"setCustomUuidButton": "Establecer UUID",
|
||||||
|
"deleteUuidTitle": "Eliminar UUID",
|
||||||
|
"deleteUuidMessage": "¿Estás seguro de que quieres eliminar el UUID de \"{username}\"? Esta acción no se puede deshacer.",
|
||||||
|
"deleteUuidButton": "Eliminar",
|
||||||
|
"uninstallGameTitle": "Desinstalar juego",
|
||||||
|
"uninstallGameMessage": "¿Estás seguro de que quieres desinstalar Hytale? Se eliminarán todos los archivos del juego.",
|
||||||
|
"uninstallGameButton": "Desinstalar"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"initializing": "Inicializando...",
|
||||||
|
"downloading": "Descargando...",
|
||||||
|
"installing": "Instalando...",
|
||||||
|
"extracting": "Extrayendo...",
|
||||||
|
"verifying": "Verificando...",
|
||||||
|
"switchingProfile": "Cambiando perfil...",
|
||||||
|
"profileSwitched": "¡Perfil cambiado!",
|
||||||
|
"startingGame": "Iniciando juego...",
|
||||||
|
"launching": "INICIANDO...",
|
||||||
|
"uninstallingGame": "Desinstalando juego...",
|
||||||
|
"gameUninstalled": "¡Juego desinstalado con éxito!",
|
||||||
|
"uninstallFailed": "Desinstalación fallida: {error}",
|
||||||
|
"startingUpdate": "Iniciando actualización obligatoria del juego...",
|
||||||
|
"installationComplete": "¡Instalación completada con éxito!",
|
||||||
|
"installationFailed": "Instalación fallida: {error}",
|
||||||
|
"installingGameFiles": "Instalando archivos del juego...",
|
||||||
|
"installComplete": "¡Instalación completa!"
|
||||||
|
}
|
||||||
|
}
|
||||||
235
GUI/locales/fr.json
Normal file
235
GUI/locales/fr.json
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"play": "Jouer",
|
||||||
|
"mods": "Mods",
|
||||||
|
"news": "Actualités",
|
||||||
|
"chat": "Chat Joueurs",
|
||||||
|
"settings": "Paramètres"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"playersLabel": "Joueurs:",
|
||||||
|
"manageProfiles": "Gérer les Profils",
|
||||||
|
"defaultProfile": "Par défaut"
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"title": "LAUNCHER GRATUIT",
|
||||||
|
"playerName": "Nom du Joueur",
|
||||||
|
"playerNamePlaceholder": "Entrez votre nom",
|
||||||
|
"gameBranch": "Version du Jeu",
|
||||||
|
"releaseVersion": "Release (Stable)",
|
||||||
|
"preReleaseVersion": "Pré-Release (Expérimental)",
|
||||||
|
"customInstallation": "Installation Personnalisée",
|
||||||
|
"installationFolder": "Dossier d'Installation",
|
||||||
|
"pathPlaceholder": "Emplacement par défaut",
|
||||||
|
"browse": "Parcourir",
|
||||||
|
"installButton": "INSTALLER HYTALE",
|
||||||
|
"installing": "INSTALLATION..."
|
||||||
|
},
|
||||||
|
"play": {
|
||||||
|
"ready": "PRÊT À JOUER",
|
||||||
|
"subtitle": "Lancez Hytale et entrez dans l'aventure",
|
||||||
|
"playButton": "JOUER À HYTALE",
|
||||||
|
"latestNews": "DERNIÈRES ACTUALITÉS",
|
||||||
|
"viewAll": "VOIR TOUT",
|
||||||
|
"checking": "VÉRIFICATION...",
|
||||||
|
"play": "JOUER"
|
||||||
|
},
|
||||||
|
"mods": {
|
||||||
|
"searchPlaceholder": "Rechercher des mods...",
|
||||||
|
"myMods": "MES MODS",
|
||||||
|
"previous": "PRÉCÉDENT",
|
||||||
|
"next": "SUIVANT",
|
||||||
|
"page": "Page",
|
||||||
|
"of": "sur",
|
||||||
|
"modalTitle": "MES MODS",
|
||||||
|
"noModsFound": "Aucun Mod Trouvé",
|
||||||
|
"noModsFoundDesc": "Essayez d'ajuster votre recherche",
|
||||||
|
"noModsInstalled": "Aucun Mod Installé",
|
||||||
|
"noModsInstalledDesc": "Ajoutez des mods depuis CurseForge ou importez des fichiers locaux",
|
||||||
|
"view": "VOIR",
|
||||||
|
"install": "INSTALLER",
|
||||||
|
"installed": "INSTALLÉ",
|
||||||
|
"enable": "ACTIVER",
|
||||||
|
"disable": "DÉSACTIVER",
|
||||||
|
"active": "ACTIF",
|
||||||
|
"disabled": "DÉSACTIVÉ",
|
||||||
|
"delete": "Supprimer le mod",
|
||||||
|
"noDescription": "Aucune description disponible",
|
||||||
|
"confirmDelete": "Êtes-vous sûr de vouloir supprimer \"{name}\" ?",
|
||||||
|
"confirmDeleteDesc": "Cette action est irréversible.",
|
||||||
|
"confirmDeletion": "Confirmer la Suppression",
|
||||||
|
"apiKeyRequired": "Clé API Requise",
|
||||||
|
"apiKeyRequiredDesc": "Une clé API CurseForge est nécessaire pour parcourir les mods"
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "TOUTES LES ACTUALITÉS",
|
||||||
|
"readMore": "Lire Plus"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "CHAT JOUEURS",
|
||||||
|
"pickColor": "Couleur",
|
||||||
|
"inputPlaceholder": "Tapez votre message...",
|
||||||
|
"send": "Envoyer",
|
||||||
|
"online": "en ligne",
|
||||||
|
"charCounter": "{current}/{max}",
|
||||||
|
"secureChat": "Chat sécurisé - Les liens sont censurés",
|
||||||
|
"joinChat": "Rejoindre le Chat",
|
||||||
|
"chooseUsername": "Choisissez un nom d'utilisateur pour rejoindre le Chat Joueurs",
|
||||||
|
"username": "Nom d'utilisateur",
|
||||||
|
"usernamePlaceholder": "Entrez votre nom d'utilisateur...",
|
||||||
|
"usernameHint": "3-20 caractères, lettres, chiffres, - et _ uniquement",
|
||||||
|
"joinButton": "Rejoindre le Chat",
|
||||||
|
"colorModal": {
|
||||||
|
"title": "Personnaliser la Couleur du Nom",
|
||||||
|
"chooseSolid": "Choisissez une couleur unie:",
|
||||||
|
"customColor": "Couleur personnalisée:",
|
||||||
|
"preview": "Aperçu:",
|
||||||
|
"previewUsername": "Nom d'utilisateur",
|
||||||
|
"apply": "Appliquer la Couleur"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "PARAMÈTRES",
|
||||||
|
"java": "Java Runtime",
|
||||||
|
"useCustomJava": "Utiliser un Chemin Java Personnalisé",
|
||||||
|
"javaDescription": "Remplacer le Java intégré par votre propre installation",
|
||||||
|
"javaPath": "Chemin de l'Exécutable Java",
|
||||||
|
"javaPathPlaceholder": "Sélectionnez le chemin Java...",
|
||||||
|
"javaBrowse": "Parcourir",
|
||||||
|
"javaHint": "Sélectionnez le dossier d'installation de Java (compatible Windows, Mac, Linux)",
|
||||||
|
"discord": "Intégration Discord",
|
||||||
|
"enableRPC": "Activer Discord Rich Presence",
|
||||||
|
"discordDescription": "Afficher votre activité du launcher sur Discord",
|
||||||
|
"game": "Options de Jeu",
|
||||||
|
"playerName": "Nom du Joueur",
|
||||||
|
"playerNamePlaceholder": "Entrez le nom du joueur",
|
||||||
|
"playerNameHint": "Ce nom sera utilisé en jeu (1-16 caractères)",
|
||||||
|
"openGameLocation": "Ouvrir l'Emplacement du Jeu",
|
||||||
|
"openGameLocationDesc": "Ouvrir le dossier d'installation du jeu",
|
||||||
|
"account": "Gestion UUID Joueur",
|
||||||
|
"currentUUID": "UUID Actuel",
|
||||||
|
"uuidPlaceholder": "Chargement UUID...",
|
||||||
|
"copyUUID": "Copier UUID",
|
||||||
|
"regenerateUUID": "Régénérer UUID",
|
||||||
|
"uuidHint": "Votre identifiant unique de joueur pour ce nom d'utilisateur",
|
||||||
|
"manageUUIDs": "Gérer Tous les UUIDs",
|
||||||
|
"manageUUIDsDesc": "Voir et gérer tous les UUIDs de joueurs",
|
||||||
|
"language": "Langue",
|
||||||
|
"selectLanguage": "Sélectionner la Langue",
|
||||||
|
"repairGame": "Réparer le Jeu",
|
||||||
|
"reinstallGame": "Réinstaller les fichiers du jeu (préserve les données)",
|
||||||
|
"gpuPreference": "Préférence GPU",
|
||||||
|
"gpuHint": "Sélectionnez votre GPU préféré (Linux: affecte DRI_PRIME)",
|
||||||
|
"gpuAuto": "Auto",
|
||||||
|
"gpuIntegrated": "Intégré",
|
||||||
|
"gpuDedicated": "Dédié",
|
||||||
|
"logs": "JOURNAUX SYSTÈME",
|
||||||
|
"logsCopy": "Copier",
|
||||||
|
"logsRefresh": "Actualiser",
|
||||||
|
"logsFolder": "Ouvrir le Dossier",
|
||||||
|
"logsLoading": "Chargement des journaux...",
|
||||||
|
"closeLauncher": "Comportement du Launcher",
|
||||||
|
"closeOnStart": "Fermer le Launcher au démarrage du jeu",
|
||||||
|
"closeOnStartDescription": "Fermer automatiquement le launcher après le lancement d'Hytale",
|
||||||
|
"hwAccel": "Accélération Matérielle",
|
||||||
|
"hwAccelDescription": "Activer l'accélération matérielle pour le launcher",
|
||||||
|
"gameBranch": "Branche du Jeu",
|
||||||
|
"branchRelease": "Release",
|
||||||
|
"branchPreRelease": "Pré-Release",
|
||||||
|
"branchHint": "Basculer entre la version stable release et la pré-release expérimentale",
|
||||||
|
"branchWarning": "Changer de branche téléchargera et installera une version différente du jeu",
|
||||||
|
"branchSwitching": "Passage à {branch}...",
|
||||||
|
"branchSwitched": "Passage à {branch} réussi!",
|
||||||
|
"installRequired": "Installation Requise",
|
||||||
|
"branchInstallConfirm": "Le jeu sera installé pour la branche {branch}. Continuer?"
|
||||||
|
},
|
||||||
|
"uuid": {
|
||||||
|
"modalTitle": "Gestion UUID",
|
||||||
|
"currentUserUUID": "UUID Utilisateur Actuel",
|
||||||
|
"allPlayerUUIDs": "Tous les UUIDs Joueurs",
|
||||||
|
"generateNew": "Générer Nouvel UUID",
|
||||||
|
"loadingUUIDs": "Chargement des UUIDs...",
|
||||||
|
"setCustomUUID": "Définir UUID Personnalisé",
|
||||||
|
"customPlaceholder": "Entrez UUID personnalisé (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
|
||||||
|
"setUUID": "Définir UUID",
|
||||||
|
"warning": "Attention: Définir un UUID personnalisé changera votre identité de joueur actuelle",
|
||||||
|
"copyTooltip": "Copier UUID",
|
||||||
|
"regenerateTooltip": "Générer Nouvel UUID"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"modalTitle": "Gérer les Profils",
|
||||||
|
"newProfilePlaceholder": "Nom du Nouveau Profil",
|
||||||
|
"createProfile": "Créer un Profil"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"notificationText": "Rejoignez notre communauté Discord!",
|
||||||
|
"joinButton": "Rejoindre Discord"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"confirm": "Confirmer",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"save": "Sauvegarder",
|
||||||
|
"close": "Fermer",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"apply": "Appliquer",
|
||||||
|
"install": "Installer"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"gameDataNotFound": "Erreur: Données du jeu introuvables",
|
||||||
|
"gameUpdatedSuccess": "Jeu mis à jour avec succès! 🎉",
|
||||||
|
"updateFailed": "Mise à jour échouée: {error}",
|
||||||
|
"updateError": "Erreur de mise à jour: {error}",
|
||||||
|
"discordEnabled": "Discord Rich Presence activé",
|
||||||
|
"discordDisabled": "Discord Rich Presence désactivé",
|
||||||
|
"discordSaveFailed": "Échec de la sauvegarde des paramètres Discord",
|
||||||
|
"playerNameRequired": "Veuillez entrer un nom de joueur valide",
|
||||||
|
"playerNameSaved": "Nom du joueur sauvegardé avec succès",
|
||||||
|
"playerNameSaveFailed": "Échec de la sauvegarde du nom du joueur",
|
||||||
|
"uuidCopied": "UUID copié dans le presse-papiers!",
|
||||||
|
"uuidCopyFailed": "Échec de la copie de l'UUID",
|
||||||
|
"uuidRegenNotAvailable": "Régénération UUID non disponible",
|
||||||
|
"uuidRegenFailed": "Échec de la régénération de l'UUID",
|
||||||
|
"uuidGenerated": "Nouvel UUID généré avec succès!",
|
||||||
|
"uuidGeneratedShort": "Nouvel UUID généré!",
|
||||||
|
"uuidGenerateFailed": "Échec de la génération du nouvel UUID",
|
||||||
|
"uuidRequired": "Veuillez entrer un UUID",
|
||||||
|
"uuidInvalidFormat": "Format UUID invalide",
|
||||||
|
"uuidSetFailed": "Échec de la définition de l'UUID personnalisé",
|
||||||
|
"uuidSetSuccess": "UUID personnalisé défini avec succès!",
|
||||||
|
"javaPathCopied": "Chemin Java copié dans le presse-papiers!",
|
||||||
|
"javaPathCopyFailed": "Échec de la copie du chemin Java",
|
||||||
|
"javaPathSaved": "Chemin Java sauvegardé avec succès!",
|
||||||
|
"javaPathSaveFailed": "Échec de la sauvegarde du chemin Java",
|
||||||
|
"javaPathInvalid": "Chemin Java invalide",
|
||||||
|
"javaPathReset": "Chemin Java réinitialisé aux valeurs par défaut",
|
||||||
|
"gameLocationError": "Impossible d'ouvrir l'emplacement du jeu",
|
||||||
|
"launcherRestartRequired": "Redémarrage du launcher requis pour appliquer les modifications",
|
||||||
|
"gameRepairConfirm": "Êtes-vous sûr de vouloir réparer le jeu? Cela réinstallera tous les fichiers du jeu.",
|
||||||
|
"gameRepairInProgress": "Réparation du jeu en cours...",
|
||||||
|
"gameRepairSuccess": "Jeu réparé avec succès!",
|
||||||
|
"gameRepairFailed": "Échec de la réparation du jeu: {error}",
|
||||||
|
"invalidUsername": "Nom d'utilisateur invalide",
|
||||||
|
"usernameInUse": "Nom d'utilisateur déjà utilisé",
|
||||||
|
"chatJoinSuccess": "Vous avez rejoint le chat!",
|
||||||
|
"chatJoinFailed": "Échec de la connexion au chat",
|
||||||
|
"messageTooLong": "Message trop long",
|
||||||
|
"messageSent": "Message envoyé",
|
||||||
|
"messageSendFailed": "Échec de l'envoi du message",
|
||||||
|
"colorUpdated": "Couleur mise à jour!",
|
||||||
|
"colorUpdateFailed": "Échec de la mise à jour de la couleur",
|
||||||
|
"profileCreated": "Profil créé avec succès!",
|
||||||
|
"profileCreateFailed": "Échec de la création du profil",
|
||||||
|
"profileDeleted": "Profil supprimé",
|
||||||
|
"profileDeleteFailed": "Échec de la suppression du profil",
|
||||||
|
"profileSwitched": "Profil changé vers: {name}",
|
||||||
|
"profileSwitchFailed": "Échec du changement de profil",
|
||||||
|
"invalidProfileName": "Nom de profil invalide",
|
||||||
|
"profileNameExists": "Un profil avec ce nom existe déjà",
|
||||||
|
"noInternet": "Pas de connexion Internet",
|
||||||
|
"checkInternetConnection": "Vérifiez votre connexion Internet",
|
||||||
|
"serverError": "Erreur serveur. Veuillez réessayer plus tard.",
|
||||||
|
"unknownError": "Une erreur inconnue s'est produite"
|
||||||
|
}
|
||||||
|
}
|
||||||
234
GUI/locales/pl-PL.json
Normal file
234
GUI/locales/pl-PL.json
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"play": "Graj",
|
||||||
|
"mods": "Mody",
|
||||||
|
"news": "Wiadomości",
|
||||||
|
"chat": "Chat z graczami",
|
||||||
|
"settings": "Ustawienia",
|
||||||
|
"skins": "Skiny"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"playersLabel": "Graczy:",
|
||||||
|
"manageProfiles": "Zarządzaj Profilami",
|
||||||
|
"defaultProfile": "Domyślny",
|
||||||
|
"f2p": "FREE TO PLAY"
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"title": "FREE TO PLAY LAUNCHER",
|
||||||
|
"playerName": "Nazwa Gracza",
|
||||||
|
"playerNamePlaceholder": "Wprowadź Nazwę",
|
||||||
|
"customInstallation": "Dostosuj Instalacje",
|
||||||
|
"installationFolder": "Folder docelowy",
|
||||||
|
"pathPlaceholder": "Domyślna lokalizacja",
|
||||||
|
"browse": "Przeglądaj",
|
||||||
|
"installButton": "ZAINSTALUJ HYTALE",
|
||||||
|
"installing": "INSTALOWANIE..."
|
||||||
|
},
|
||||||
|
"play": {
|
||||||
|
"ready": "GOTOWE",
|
||||||
|
"subtitle": "Uruchom Hytale i rozpocznij przygodę",
|
||||||
|
"playButton": "GRAJ W HYTALE",
|
||||||
|
"latestNews": "NAJNOWSZE WIADOMOŚCI",
|
||||||
|
"viewAll": "ZOBACZ CAŁOŚĆ",
|
||||||
|
"checking": "SPRAWDZANIE...",
|
||||||
|
"play": "GRAJ"
|
||||||
|
},
|
||||||
|
"mods": {
|
||||||
|
"searchPlaceholder": "Wyszukaj mody...",
|
||||||
|
"myMods": "MOJE MODY",
|
||||||
|
"previous": "POPRZEDNIA",
|
||||||
|
"next": "NASTĘPNA",
|
||||||
|
"page": "Strona",
|
||||||
|
"of": "z",
|
||||||
|
"modalTitle": "MOJE MODY",
|
||||||
|
"noModsFound": "Nie Znaleziono Modów",
|
||||||
|
"noModsFoundDesc": "Spróbuj dostosować wyszukiwanie",
|
||||||
|
"noModsInstalled": "Brak Zainstalowanych Modów",
|
||||||
|
"noModsInstalledDesc": "Dodaj mody z CurseForge lub zaimportuj lokalne pliki",
|
||||||
|
"view": "WIDOK",
|
||||||
|
"install": "ZAINSTALUJ",
|
||||||
|
"installed": "ZAINSTALOWANE",
|
||||||
|
"enable": "WŁĄCZ",
|
||||||
|
"disable": "WYŁĄCZ",
|
||||||
|
"active": "AKTYWNE",
|
||||||
|
"disabled": "WYŁĄCZONE",
|
||||||
|
"delete": "Usuń mod",
|
||||||
|
"noDescription": "Brak opisu",
|
||||||
|
"confirmDelete": "Czy na pewno chcesz usunąć \"{name}\"?",
|
||||||
|
"confirmDeleteDesc": "Tej czynności nie można cofnąć.",
|
||||||
|
"confirmDeletion": "Potwierdź"
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "WSZYSTKIE WIADOMOŚCI",
|
||||||
|
"readMore": "Zobacz Więcej"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "Chat z graczami",
|
||||||
|
"pickColor": "Kolor",
|
||||||
|
"inputPlaceholder": "Wprowadź swoją wiadomość...",
|
||||||
|
"send": "Wyślij",
|
||||||
|
"online": "online",
|
||||||
|
"charCounter": "{current}/{max}",
|
||||||
|
"secureChat": "Bezpieczny czat – Linki są ocenzurowane",
|
||||||
|
"joinChat": "Dołącz do Czatu",
|
||||||
|
"chooseUsername": "Wybierz nazwę użytkownika, aby dołączyć do Czatu z graczami",
|
||||||
|
"username": "Nazwa Gracza",
|
||||||
|
"usernamePlaceholder": "Wprowadź swoją nazwę...",
|
||||||
|
"usernameHint": "Między 3-20 znaków, tylko litery, cyfry i znaki - i _",
|
||||||
|
"joinButton": "Dołącz do Czatu",
|
||||||
|
"colorModal": {
|
||||||
|
"title": "Dostosuj Kolor Użytkownika",
|
||||||
|
"chooseSolid": "Wybierz jednolity kolor:",
|
||||||
|
"customColor": "Kolor niestandardowy:",
|
||||||
|
"preview": "Podgląd:",
|
||||||
|
"previewUsername": "Nazwa",
|
||||||
|
"apply": "Zastosuj Kolor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "USTAWIENIA",
|
||||||
|
"java": "Środowisko Java",
|
||||||
|
"useCustomJava": "Użyj niestandardowej ścieżki Java",
|
||||||
|
"javaDescription": "Zastąp dołączone środowisko wykonawcze Java własnym",
|
||||||
|
"javaPath": "Ścieżka Wykonywalna Java",
|
||||||
|
"javaPathPlaceholder": "Wybierz ścieżkę Java...",
|
||||||
|
"javaBrowse": "Przeglądaj",
|
||||||
|
"javaHint": "Wybierz folder instalacyjny Java (obsługiwane Windows, Mac, Linux)",
|
||||||
|
"discord": "Integracja z Discordem",
|
||||||
|
"enableRPC": "Włącz Discord Rich Presence",
|
||||||
|
"discordDescription": "Pokaż swoją aktywność na Discordzie",
|
||||||
|
"game": "Opcje gry",
|
||||||
|
"playerName": "Nazwa Gracza",
|
||||||
|
"playerNamePlaceholder": "Wprowadź swoją nazwę",
|
||||||
|
"playerNameHint": "Ta nazwa będzie używana w grze (1-16 znaków)",
|
||||||
|
"openGameLocation": "Otwórz Lokalizację Gry",
|
||||||
|
"openGameLocationDesc": "Otwórz folder instalacyjny gry",
|
||||||
|
"account": "Zarządzanie identyfikatorami UUID gracza",
|
||||||
|
"currentUUID": "Obecny UUID",
|
||||||
|
"uuidPlaceholder": "Ładowanie UUID...",
|
||||||
|
"copyUUID": "Skopiuj UUID",
|
||||||
|
"regenerateUUID": "Generuj UUID",
|
||||||
|
"uuidHint": "Twój unikalny identyfikator gracza dla tej nazwy użytkownika",
|
||||||
|
"manageUUIDs": "Zarządzaj wszystkimi UUID",
|
||||||
|
"manageUUIDsDesc": "Wyświetl i zarządzaj wszystkimi identyfikatorami UUID graczy",
|
||||||
|
"language": "Język",
|
||||||
|
"selectLanguage": "Wybierz Język",
|
||||||
|
"repairGame": "Napraw Grę",
|
||||||
|
"reinstallGame": "Zainstaluj ponownie pliki gry (zachowuje dane)",
|
||||||
|
"gpuPreference": "Preferencje GPU",
|
||||||
|
"gpuHint": "Wybierz preferowany procesor graficzny (Linux: wpływa na DRI_PRIME)",
|
||||||
|
"gpuAuto": "Auto",
|
||||||
|
"gpuIntegrated": "Zintegrowana",
|
||||||
|
"gpuDedicated": "Dedykowana",
|
||||||
|
"logs": "SYSTEM LOGS",
|
||||||
|
"logsCopy": "Kopiuj",
|
||||||
|
"logsRefresh": "Odśwież",
|
||||||
|
"logsFolder": "Otwórz Folder",
|
||||||
|
"logsLoading": "Ładowanie logów..."
|
||||||
|
},
|
||||||
|
"uuid": {
|
||||||
|
"modalTitle": "Zarządzanie UUID",
|
||||||
|
"currentUserUUID": "Aktualny UUID użytkownika",
|
||||||
|
"allPlayerUUIDs": "Wszystkie identyfikatory UUID graczy",
|
||||||
|
"generateNew": "Wygeneruj nowy UUID",
|
||||||
|
"loadingUUIDs": "Ładowanie UUID...",
|
||||||
|
"setCustomUUID": "Ustaw niestandardowy UUID",
|
||||||
|
"customPlaceholder": "Wprowadź niestandardowy UUID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
|
||||||
|
"setUUID": "Ustaw UUID",
|
||||||
|
"warning": "Ostrzeżenie: Ustawienie niestandardowego identyfikatora UUID spowoduje zmianę Twojego obecnego identyfikatora gracza",
|
||||||
|
"copyTooltip": "Kopiuj UUID",
|
||||||
|
"regenerateTooltip": "Wygeneruj nowy UUID"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"modalTitle": "Zarządzaj Profilami",
|
||||||
|
"newProfilePlaceholder": "Nowa Nazwa Profilu",
|
||||||
|
"createProfile": "Utwórz Profil"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"notificationText": "Dołącz do naszej społeczności Discord!",
|
||||||
|
"joinButton": "Dołącz Discord"
|
||||||
|
},
|
||||||
|
"skins": {
|
||||||
|
"title": "Skiny",
|
||||||
|
"comingSoon": "Personalizacja skórek już wkrótce..."
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"confirm": "Potwierdź",
|
||||||
|
"cancel": "Anuluj",
|
||||||
|
"save": "Zapisz",
|
||||||
|
"close": "Zamknij",
|
||||||
|
"delete": "Usuń",
|
||||||
|
"edit": "Edytuj",
|
||||||
|
"loading": "Ładowanie...",
|
||||||
|
"apply": "Zastosuj"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"gameDataNotFound": "Błąd: Nie znaleziono danych gry",
|
||||||
|
"gameUpdatedSuccess": "Gra została zaktualizowana pomyślnie! 🎉",
|
||||||
|
"updateFailed": "Aktualizacja nie powiodła się: {error}",
|
||||||
|
"updateError": "Błąd aktualizacji: {error}",
|
||||||
|
"discordEnabled": "Discord Rich Presence włączony",
|
||||||
|
"discordDisabled": "Discord Rich Presence wyłączony",
|
||||||
|
"discordSaveFailed": "Nie udało się zapisać ustawień Discorda",
|
||||||
|
"playerNameRequired": "Proszę podać prawidłową nazwę gracza",
|
||||||
|
"playerNameSaved": "Nazwa gracza została zapisana pomyślnie",
|
||||||
|
"playerNameSaveFailed": "Nie udało się zapisać nazwy gracza",
|
||||||
|
"uuidCopied": "Identyfikator UUID skopiowany do schowka!",
|
||||||
|
"uuidCopyFailed": "Nie udało się skopiować UUID",
|
||||||
|
"uuidRegenNotAvailable": "Ponowna gerowanie UUID niedostępne",
|
||||||
|
"uuidRegenFailed": "Nie udało się ponownie wygenerować UUID",
|
||||||
|
"uuidGenerated": "Nowy UUID został pomyślnie wygenerowany!",
|
||||||
|
"uuidGeneratedShort": "Wygenerowano nowy UUID!",
|
||||||
|
"uuidGenerateFailed": "Nie udało się wygenerować nowego UUID",
|
||||||
|
"uuidRequired": "Wprowadzić UUID",
|
||||||
|
"uuidInvalidFormat": "Nieprawidłowy format UUID",
|
||||||
|
"uuidSetFailed": "Nie udało się ustawić niestandardowego UUID",
|
||||||
|
"uuidSetSuccess": "Niestandardowy UUID został ustawiony pomyślnie!",
|
||||||
|
"uuidDeleteFailed": "Nie udało się usunąć UUID",
|
||||||
|
"uuidDeleteSuccess": "UUID został pomyślnie usunięty!",
|
||||||
|
"modsDownloading": "Pobieranie {name}...",
|
||||||
|
"modsTogglingMod": "Przełączanie moda...",
|
||||||
|
"modsDeletingMod": "Usuwanie moda...",
|
||||||
|
"modsLoadingMods": "Ładowanie modów z CurseForge...",
|
||||||
|
"modsInstalledSuccess": "{name} zainstalowany pomyślnie! 🎉",
|
||||||
|
"modsDeletedSuccess": "{name} usunięto pomyślnie",
|
||||||
|
"modsDownloadFailed": "Nie udało się pobrać moda: {error}",
|
||||||
|
"modsToggleFailed": "Nie udało się przełączyć moda: {error}",
|
||||||
|
"modsDeleteFailed": "Nie udało się usunąć moda: {error}",
|
||||||
|
"modsModNotFound": "Nie znaleziono informacji o modzie"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"defaultTitle": "Potwierdź działanie",
|
||||||
|
"regenerateUuidTitle": "Wygeneruj nowy UUID",
|
||||||
|
"regenerateUuidMessage": "Czy na pewno chcesz wygenerować nowy UUID? To spowoduje zmianę Twojego identyfikatora gracza.",
|
||||||
|
"regenerateUuidButton": "Generuj",
|
||||||
|
"setCustomUuidTitle": "Ustaw niestandardowy UUID",
|
||||||
|
"setCustomUuidMessage": "Czy na pewno chcesz ustawić ten UUID? To spowoduje zmianę Twojego identyfikatora gracza.",
|
||||||
|
"setCustomUuidButton": "Ustaw UUID",
|
||||||
|
"deleteUuidTitle": "Usuń UUID",
|
||||||
|
"deleteUuidMessage": "Czy na pewno chcesz usunąć UUID dla \"{username}\"? Tej czynności nie można cofnąć.",
|
||||||
|
"deleteUuidButton": "Usuń",
|
||||||
|
"uninstallGameTitle": "Odinstaluj grę",
|
||||||
|
"uninstallGameMessage": "Czy na pewno chcesz odinstalować Hytale? Wszystkie pliki gry zostaną usunięte.",
|
||||||
|
"uninstallGameButton": "Odinstaluj"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"initializing": "Inicjalizacja...",
|
||||||
|
"downloading": "Pobieranie...",
|
||||||
|
"installing": "Instalowanie...",
|
||||||
|
"extracting": "Ekstraktowanie...",
|
||||||
|
"verifying": "Weryfikowanie...",
|
||||||
|
"switchingProfile": "Przełączanie profilu...",
|
||||||
|
"profileSwitched": "Profil zmieniony!",
|
||||||
|
"startingGame": "Uruchamianie gry...",
|
||||||
|
"launching": "URUCHAMIANIE...",
|
||||||
|
"uninstallingGame": "Odinstalowywanie gry...",
|
||||||
|
"gameUninstalled": "Gra została pomyślnie odinstalowana!",
|
||||||
|
"uninstallFailed": "Odinstalowanie nie powiodło się: {error}",
|
||||||
|
"startingUpdate": "Rozpoczynanie obowiązkowej aktualizacji gry...",
|
||||||
|
"installationComplete": "Instalacja zakończona pomyślnie!",
|
||||||
|
"installationFailed": "Instalacja nie powiodła się: {error}",
|
||||||
|
"installingGameFiles": "Instalowanie plików gry...",
|
||||||
|
"installComplete": "Instalacja zakończona!"
|
||||||
|
}
|
||||||
|
}
|
||||||
245
GUI/locales/pt-BR.json
Normal file
245
GUI/locales/pt-BR.json
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"play": "Jogar",
|
||||||
|
"mods": "Mods",
|
||||||
|
"news": "Notícias",
|
||||||
|
"chat": "Chat de Jogadores",
|
||||||
|
"settings": "Configurações"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"playersLabel": "Jogadores:",
|
||||||
|
"manageProfiles": "Gerenciar Perfis",
|
||||||
|
"defaultProfile": "Padrão"
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"title": "LANÇADOR JOGO GRATUITO",
|
||||||
|
"playerName": "Nome do Jogador",
|
||||||
|
"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",
|
||||||
|
"installButton": "INSTALAR HYTALE",
|
||||||
|
"installing": "INSTALANDO..."
|
||||||
|
},
|
||||||
|
"play": {
|
||||||
|
"ready": "PRONTO PARA JOGAR",
|
||||||
|
"subtitle": "Inicie Hytale e entre na aventura",
|
||||||
|
"playButton": "JOGAR HYTALE",
|
||||||
|
"latestNews": "ÚLTIMAS NOTÍCIAS",
|
||||||
|
"viewAll": "VER TUDO",
|
||||||
|
"checking": "VERIFICANDO...",
|
||||||
|
"play": "JOGAR"
|
||||||
|
},
|
||||||
|
"mods": {
|
||||||
|
"searchPlaceholder": "Pesquisar mods...",
|
||||||
|
"myMods": "MEUS MODS",
|
||||||
|
"previous": "ANTERIOR",
|
||||||
|
"next": "PRÓXIMO",
|
||||||
|
"page": "Página",
|
||||||
|
"of": "de",
|
||||||
|
"modalTitle": "MEUS MODS",
|
||||||
|
"noModsFound": "Nenhum mod encontrado",
|
||||||
|
"noModsFoundDesc": "Tente ajustar sua pesquisa",
|
||||||
|
"noModsInstalled": "Nenhum mod instalado",
|
||||||
|
"noModsInstalledDesc": "Adicione mods do CurseForge ou importe arquivos locais",
|
||||||
|
"view": "VER",
|
||||||
|
"install": "INSTALAR",
|
||||||
|
"installed": "INSTALADO",
|
||||||
|
"enable": "ATIVAR",
|
||||||
|
"disable": "DESATIVAR",
|
||||||
|
"active": "ATIVO",
|
||||||
|
"disabled": "DESATIVADO",
|
||||||
|
"delete": "Excluir mod",
|
||||||
|
"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",
|
||||||
|
"apiKeyRequired": "Chave de API Necessária",
|
||||||
|
"apiKeyRequiredDesc": "Chave de API do CurseForge é necessária para procurar mods"
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "TODAS AS NOTÍCIAS",
|
||||||
|
"readMore": "Leia mais"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "CHAT DE JOGADORES",
|
||||||
|
"pickColor": "Cor",
|
||||||
|
"inputPlaceholder": "Digite sua mensagem...",
|
||||||
|
"send": "Enviar",
|
||||||
|
"online": "online",
|
||||||
|
"charCounter": "{current}/{max}",
|
||||||
|
"secureChat": "Chat seguro - Links são censurados",
|
||||||
|
"joinChat": "Entrar no chat",
|
||||||
|
"chooseUsername": "Escolha um nome de usuário para entrar no chat de jogadores",
|
||||||
|
"username": "Nome de usuário",
|
||||||
|
"usernamePlaceholder": "Digite seu nome de usuário...",
|
||||||
|
"usernameHint": "3-20 caracteres, letras, números, - e _ apenas",
|
||||||
|
"joinButton": "Entrar no Chat",
|
||||||
|
"colorModal": {
|
||||||
|
"title": "Personalizar cor do nome de usuário",
|
||||||
|
"chooseSolid": "Escolha uma cor sólida:",
|
||||||
|
"customColor": "Cor personalizada:",
|
||||||
|
"preview": "Visualização:",
|
||||||
|
"previewUsername": "Nome de usuário",
|
||||||
|
"apply": "Aplicar cor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "CONFIGURAÇÕES",
|
||||||
|
"java": "Tempo de execução Java",
|
||||||
|
"useCustomJava": "Usar caminho personalizado do Java",
|
||||||
|
"javaDescription": "Substitua o tempo de execução Java incluído pela sua própria instalação",
|
||||||
|
"javaPath": "Caminho do executável Java",
|
||||||
|
"javaPathPlaceholder": "Selecione o caminho do Java...",
|
||||||
|
"javaBrowse": "Procurar",
|
||||||
|
"javaHint": "Selecione a pasta de instalação do Java (suporta Windows, Mac, Linux)",
|
||||||
|
"discord": "Integração do Discord",
|
||||||
|
"enableRPC": "Ativar Discord Rich Presence",
|
||||||
|
"discordDescription": "Mostre sua atividade do lançador no Discord",
|
||||||
|
"game": "Opções do jogo",
|
||||||
|
"playerName": "Nome do jogador",
|
||||||
|
"playerNamePlaceholder": "Digite seu nome de jogador",
|
||||||
|
"playerNameHint": "Este nome será usado no jogo (1-16 caracteres)",
|
||||||
|
"openGameLocation": "Abrir local do jogo",
|
||||||
|
"openGameLocationDesc": "Abra a pasta de instalação do jogo",
|
||||||
|
"account": "Gerenciamento de UUID do jogador",
|
||||||
|
"currentUUID": "UUID atual",
|
||||||
|
"uuidPlaceholder": "Carregando UUID...",
|
||||||
|
"copyUUID": "Copiar UUID",
|
||||||
|
"regenerateUUID": "Regenerar UUID",
|
||||||
|
"uuidHint": "Seu identificador único de jogador para este nome de usuário",
|
||||||
|
"manageUUIDs": "Gerenciar todos os UUIDs",
|
||||||
|
"manageUUIDsDesc": "Ver e gerenciar todos os UUIDs de jogadores",
|
||||||
|
"language": "Idioma",
|
||||||
|
"selectLanguage": "Selecionar idioma",
|
||||||
|
"repairGame": "Reparar jogo",
|
||||||
|
"reinstallGame": "Reinstalar arquivos do jogo (mantém os dados)",
|
||||||
|
"gpuPreference": "Preferência de GPU",
|
||||||
|
"gpuHint": "Selecione sua GPU preferida (Linux: afeta o DRI_PRIME)",
|
||||||
|
"gpuAuto": "Automático",
|
||||||
|
"gpuIntegrated": "Integrada",
|
||||||
|
"gpuDedicated": "Dedicada",
|
||||||
|
"logs": "REGISTROS DO SISTEMA",
|
||||||
|
"logsCopy": "Copiar",
|
||||||
|
"logsRefresh": "Atualizar",
|
||||||
|
"logsFolder": "Abrir Pasta",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"currentUserUUID": "UUID do usuário atual",
|
||||||
|
"allPlayerUUIDs": "Todos os UUIDs de jogadores",
|
||||||
|
"generateNew": "Gerar novo UUID",
|
||||||
|
"loadingUUIDs": "Carregando UUIDs...",
|
||||||
|
"setCustomUUID": "Definir UUID personalizado",
|
||||||
|
"customPlaceholder": "Digite um UUID personalizado (formato: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
|
||||||
|
"setUUID": "Definir UUID",
|
||||||
|
"warning": "Aviso: Definir um UUID personalizado alterará sua identidade de jogador atual",
|
||||||
|
"copyTooltip": "Copiar UUID",
|
||||||
|
"regenerateTooltip": "Gerar novo UUID"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"modalTitle": "Gerenciar perfis",
|
||||||
|
"newProfilePlaceholder": "Nome do novo perfil",
|
||||||
|
"createProfile": "Criar perfil"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"notificationText": "Junte-se à nossa comunidade do Discord!",
|
||||||
|
"joinButton": "Entrar no Discord"
|
||||||
|
},
|
||||||
|
|
||||||
|
"common": {
|
||||||
|
"confirm": "Confirmar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"save": "Salvar",
|
||||||
|
"close": "Fechar",
|
||||||
|
"delete": "Excluir",
|
||||||
|
"edit": "Editar",
|
||||||
|
"loading": "Carregando...",
|
||||||
|
"apply": "Aplicar",
|
||||||
|
"install": "Instalar"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"gameDataNotFound": "Erro: Dados do jogo não encontrados",
|
||||||
|
"gameUpdatedSuccess": "Jogo atualizado com sucesso! 🎉",
|
||||||
|
"updateFailed": "Falha na atualização: {error}",
|
||||||
|
"updateError": "Erro de atualização: {error}",
|
||||||
|
"discordEnabled": "Discord Rich Presence ativado",
|
||||||
|
"discordDisabled": "Discord Rich Presence desativado",
|
||||||
|
"discordSaveFailed": "Falha ao salvar configuração do Discord",
|
||||||
|
"playerNameRequired": "Por favor, digite um nome de jogador válido",
|
||||||
|
"playerNameSaved": "Nome do jogador salvo com sucesso",
|
||||||
|
"playerNameSaveFailed": "Falha ao salvar o nome do jogador",
|
||||||
|
"uuidCopied": "UUID copiado para a área de transferência!",
|
||||||
|
"uuidCopyFailed": "Falha ao copiar UUID",
|
||||||
|
"uuidRegenNotAvailable": "Regeneração de UUID não disponível",
|
||||||
|
"uuidRegenFailed": "Falha ao regenerar UUID",
|
||||||
|
"uuidGenerated": "Novo UUID gerado com sucesso!",
|
||||||
|
"uuidGeneratedShort": "Novo UUID gerado!",
|
||||||
|
"uuidGenerateFailed": "Falha ao gerar novo UUID",
|
||||||
|
"uuidRequired": "Por favor, digite um UUID",
|
||||||
|
"uuidInvalidFormat": "Formato de UUID inválido",
|
||||||
|
"uuidSetFailed": "Falha ao definir UUID personalizado",
|
||||||
|
"uuidSetSuccess": "UUID personalizado definido com sucesso!",
|
||||||
|
"uuidDeleteFailed": "Falha ao excluir UUID",
|
||||||
|
"uuidDeleteSuccess": "UUID excluído com sucesso!",
|
||||||
|
"modsDownloading": "Baixando {name}...",
|
||||||
|
"modsTogglingMod": "Alternando mod...",
|
||||||
|
"modsDeletingMod": "Excluindo mod...",
|
||||||
|
"modsLoadingMods": "Carregando mods do CurseForge...",
|
||||||
|
"modsInstalledSuccess": "{name} instalado com sucesso! 🎉",
|
||||||
|
"modsDeletedSuccess": "{name} excluído com sucesso",
|
||||||
|
"modsDownloadFailed": "Falha ao baixar mod: {error}",
|
||||||
|
"modsToggleFailed": "Falha ao alternar mod: {error}",
|
||||||
|
"modsDeleteFailed": "Falha ao excluir mod: {error}",
|
||||||
|
"modsModNotFound": "Informações do mod não encontradas"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"defaultTitle": "Confirmar ação",
|
||||||
|
"regenerateUuidTitle": "Gerar novo UUID",
|
||||||
|
"regenerateUuidMessage": "Tem certeza de que deseja gerar um novo UUID? Isso alterará sua identidade de jogador.",
|
||||||
|
"regenerateUuidButton": "Gerar",
|
||||||
|
"setCustomUuidTitle": "Definir UUID personalizado",
|
||||||
|
"setCustomUuidMessage": "Tem certeza de que deseja definir este UUID personalizado? Isso alterará sua identidade de jogador.",
|
||||||
|
"setCustomUuidButton": "Definir UUID",
|
||||||
|
"deleteUuidTitle": "Excluir UUID",
|
||||||
|
"deleteUuidMessage": "Tem certeza de que deseja excluir o UUID de \"{username}\"? Esta ação não pode ser desfeita.",
|
||||||
|
"deleteUuidButton": "Excluir",
|
||||||
|
"uninstallGameTitle": "Desinstalar jogo",
|
||||||
|
"uninstallGameMessage": "Tem certeza de que deseja desinstalar Hytale? Todos os arquivos do jogo serão excluídos.",
|
||||||
|
"uninstallGameButton": "Desinstalar"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"initializing": "Inicializando...",
|
||||||
|
"downloading": "Baixando...",
|
||||||
|
"installing": "Instalando...",
|
||||||
|
"extracting": "Extraindo...",
|
||||||
|
"verifying": "Verificando...",
|
||||||
|
"switchingProfile": "Alternando perfil...",
|
||||||
|
"profileSwitched": "Perfil alternado!",
|
||||||
|
"startingGame": "Iniciando jogo...",
|
||||||
|
"launching": "INICIANDO...",
|
||||||
|
"uninstallingGame": "Desinstalando jogo...",
|
||||||
|
"gameUninstalled": "Jogo desinstalado com sucesso!",
|
||||||
|
"uninstallFailed": "Falha na desinstalação: {error}",
|
||||||
|
"startingUpdate": "Iniciando atualização obrigatória do jogo...",
|
||||||
|
"installationComplete": "Instalação concluída com sucesso!",
|
||||||
|
"installationFailed": "Falha na instalação: {error}",
|
||||||
|
"installingGameFiles": "Instalando arquivos do jogo...",
|
||||||
|
"installComplete": "Instalação concluída!"
|
||||||
|
}
|
||||||
|
}
|
||||||
283
GUI/locales/sv.json
Normal file
283
GUI/locales/sv.json
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"play": "Spela",
|
||||||
|
"mods": "Moddar",
|
||||||
|
"news": "Nyheter",
|
||||||
|
"chat": "Spelarchatt",
|
||||||
|
"settings": "Inställningar"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"playersLabel": "Spelare:",
|
||||||
|
"manageProfiles": "Hantera profiler",
|
||||||
|
"defaultProfile": "Standard"
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"title": "GRATIS LAUNCHER",
|
||||||
|
"playerName": "Spelarnamn",
|
||||||
|
"playerNamePlaceholder": "Ange ditt namn",
|
||||||
|
"gameBranch": "Spelversion",
|
||||||
|
"releaseVersion": "Release (Stabil)",
|
||||||
|
"preReleaseVersion": "Pre-Release (Experimentell)",
|
||||||
|
"customInstallation": "Anpassad installation",
|
||||||
|
"installationFolder": "Installationsmapp",
|
||||||
|
"pathPlaceholder": "Standardplats",
|
||||||
|
"browse": "Bläddra",
|
||||||
|
"installButton": "INSTALLERA HYTALE",
|
||||||
|
"installing": "INSTALLERAR..."
|
||||||
|
},
|
||||||
|
"play": {
|
||||||
|
"ready": "REDO ATT SPELA",
|
||||||
|
"subtitle": "Starta Hytale och börja äventyret",
|
||||||
|
"playButton": "SPELA HYTALE",
|
||||||
|
"latestNews": "SENASTE NYHETERNA",
|
||||||
|
"viewAll": "VISA ALLA",
|
||||||
|
"checking": "KONTROLLERAR...",
|
||||||
|
"play": "SPELA"
|
||||||
|
},
|
||||||
|
"mods": {
|
||||||
|
"searchPlaceholder": "Sök moddar...",
|
||||||
|
"myMods": "MINA MODDAR",
|
||||||
|
"previous": "FÖREGÅENDE",
|
||||||
|
"next": "NÄSTA",
|
||||||
|
"page": "Sida",
|
||||||
|
"of": "av",
|
||||||
|
"modalTitle": "MINA MODDAR",
|
||||||
|
"noModsFound": "Inga moddar hittades",
|
||||||
|
"noModsFoundDesc": "Försök justera din sökning",
|
||||||
|
"noModsInstalled": "Inga moddar installerade",
|
||||||
|
"noModsInstalledDesc": "Lägg till moddar från CurseForge eller importera lokala filer",
|
||||||
|
"view": "VISA",
|
||||||
|
"install": "INSTALLERA",
|
||||||
|
"installed": "INSTALLERAD",
|
||||||
|
"enable": "AKTIVERA",
|
||||||
|
"disable": "INAKTIVERA",
|
||||||
|
"active": "AKTIV",
|
||||||
|
"disabled": "INAKTIVERAD",
|
||||||
|
"delete": "Ta bort modd",
|
||||||
|
"noDescription": "Ingen beskrivning tillgänglig",
|
||||||
|
"confirmDelete": "Är du säker på att du vill ta bort \"{name}\"?",
|
||||||
|
"confirmDeleteDesc": "Denna åtgärd kan inte ångras.",
|
||||||
|
"confirmDeletion": "Bekräfta borttagning",
|
||||||
|
"apiKeyRequired": "API-nyckel krävs",
|
||||||
|
"apiKeyRequiredDesc": "CurseForge API-nyckel behövs för att bläddra bland moddar"
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "ALLA NYHETER",
|
||||||
|
"readMore": "Läs mer"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "SPELARCHATT",
|
||||||
|
"pickColor": "Färg",
|
||||||
|
"inputPlaceholder": "Skriv ditt meddelande...",
|
||||||
|
"send": "Skicka",
|
||||||
|
"online": "online",
|
||||||
|
"charCounter": "{current}/{max}",
|
||||||
|
"secureChat": "Säker chatt - Länkar är censurerade",
|
||||||
|
"joinChat": "Gå med i chatten",
|
||||||
|
"chooseUsername": "Välj ett användarnamn för att gå med i spelarchartten",
|
||||||
|
"username": "Användarnamn",
|
||||||
|
"usernamePlaceholder": "Ange ditt användarnamn...",
|
||||||
|
"usernameHint": "3-20 tecken, endast bokstäver, siffror, - och _",
|
||||||
|
"joinButton": "Gå med i chatten",
|
||||||
|
"colorModal": {
|
||||||
|
"title": "Anpassa användarnamnsfargen",
|
||||||
|
"chooseSolid": "Välj en enfärgad färg:",
|
||||||
|
"customColor": "Anpassad färg:",
|
||||||
|
"preview": "Förhandsvisning:",
|
||||||
|
"previewUsername": "Användarnamn",
|
||||||
|
"apply": "Använd färg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "INSTÄLLNINGAR",
|
||||||
|
"java": "Java Runtime",
|
||||||
|
"useCustomJava": "Använd anpassad Java-sökväg",
|
||||||
|
"javaDescription": "Ersätt den medföljande Java-installationen med din egen",
|
||||||
|
"javaPath": "Java-körbar fil-sökväg",
|
||||||
|
"javaPathPlaceholder": "Välj Java-sökväg...",
|
||||||
|
"javaBrowse": "Bläddra",
|
||||||
|
"javaHint": "Välj Java-installationsmappen (stöder Windows, Mac, Linux)",
|
||||||
|
"discord": "Discord-integration",
|
||||||
|
"enableRPC": "Aktivera Discord Rich Presence",
|
||||||
|
"discordDescription": "Visa din launcher-aktivitet på Discord",
|
||||||
|
"game": "Spelalternativ",
|
||||||
|
"playerName": "Spelarnamn",
|
||||||
|
"playerNamePlaceholder": "Ange spelarnamn",
|
||||||
|
"playerNameHint": "Detta namn kommer att användas i spelet (1-16 tecken)",
|
||||||
|
"openGameLocation": "Öppna spelplats",
|
||||||
|
"openGameLocationDesc": "Öppna spelinstallationsmappen",
|
||||||
|
"account": "Spelare UUID-hantering",
|
||||||
|
"currentUUID": "Nuvarande UUID",
|
||||||
|
"uuidPlaceholder": "Laddar UUID...",
|
||||||
|
"copyUUID": "Kopiera UUID",
|
||||||
|
"regenerateUUID": "Återskapa UUID",
|
||||||
|
"uuidHint": "Din unika spelaridentifierare för detta användarnamn",
|
||||||
|
"manageUUIDs": "Hantera alla UUID:er",
|
||||||
|
"manageUUIDsDesc": "Visa och hantera alla spelare-UUID:er",
|
||||||
|
"language": "Språk",
|
||||||
|
"selectLanguage": "Välj språk",
|
||||||
|
"repairGame": "Reparera spel",
|
||||||
|
"reinstallGame": "Ominstallera spelfiler (bevarar data)",
|
||||||
|
"gpuPreference": "GPU-preferens",
|
||||||
|
"gpuHint": "Välj din föredragna GPU (Linux: påverkar DRI_PRIME)",
|
||||||
|
"gpuAuto": "Auto",
|
||||||
|
"gpuIntegrated": "Integrerad",
|
||||||
|
"gpuDedicated": "Dedikerad",
|
||||||
|
"logs": "SYSTEMLOGGAR",
|
||||||
|
"logsCopy": "Kopiera",
|
||||||
|
"logsRefresh": "Uppdatera",
|
||||||
|
"logsFolder": "Öppna mapp",
|
||||||
|
"logsLoading": "Laddar loggar...",
|
||||||
|
"closeLauncher": "Launcher-beteende",
|
||||||
|
"closeOnStart": "Stäng launcher vid spelstart",
|
||||||
|
"closeOnStartDescription": "Stäng automatiskt launcher efter att Hytale har startats",
|
||||||
|
"hwAccel": "Hårdvaruacceleration",
|
||||||
|
"hwAccelDescription": "Aktivera hårdvaruacceleration för launchern",
|
||||||
|
"gameBranch": "Spelgren",
|
||||||
|
"branchRelease": "Release",
|
||||||
|
"branchPreRelease": "Pre-Release",
|
||||||
|
"branchHint": "Växla mellan stabil release- och experimentell pre-release-version",
|
||||||
|
"branchWarning": "Att byta gren kommer att ladda ner och installera en annan spelversion",
|
||||||
|
"branchSwitching": "Byter till {branch}...",
|
||||||
|
"branchSwitched": "Bytte framgångsrikt till {branch}!",
|
||||||
|
"installRequired": "Installation krävs",
|
||||||
|
"branchInstallConfirm": "Spelet kommer att installeras för {branch}-grenen. Fortsätt?"
|
||||||
|
},
|
||||||
|
"uuid": {
|
||||||
|
"modalTitle": "UUID-hantering",
|
||||||
|
"currentUserUUID": "Nuvarande användar-UUID",
|
||||||
|
"allPlayerUUIDs": "Alla spelare-UUID:er",
|
||||||
|
"generateNew": "Generera ny UUID",
|
||||||
|
"loadingUUIDs": "Laddar UUID:er...",
|
||||||
|
"setCustomUUID": "Ange anpassad UUID",
|
||||||
|
"customPlaceholder": "Ange anpassad UUID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
|
||||||
|
"setUUID": "Ange UUID",
|
||||||
|
"warning": "Varning: Att ange en anpassad UUID kommer att ändra din nuvarande spelaridentitet",
|
||||||
|
"copyTooltip": "Kopiera UUID",
|
||||||
|
"regenerateTooltip": "Generera ny UUID"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"modalTitle": "Hantera profiler",
|
||||||
|
"newProfilePlaceholder": "Nytt profilnamn",
|
||||||
|
"createProfile": "Skapa profil"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"notificationText": "Gå med i vår Discord-gemenskap!",
|
||||||
|
"joinButton": "Gå med i Discord"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"confirm": "Bekräfta",
|
||||||
|
"cancel": "Avbryt",
|
||||||
|
"save": "Spara",
|
||||||
|
"close": "Stäng",
|
||||||
|
"delete": "Ta bort",
|
||||||
|
"edit": "Redigera",
|
||||||
|
"loading": "Laddar...",
|
||||||
|
"apply": "Verkställ",
|
||||||
|
"install": "Installera"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"gameDataNotFound": "Fel: Speldata hittades inte",
|
||||||
|
"gameUpdatedSuccess": "Spelet uppdaterades framgångsrikt! 🎉",
|
||||||
|
"updateFailed": "Uppdatering misslyckades: {error}",
|
||||||
|
"updateError": "Uppdateringsfel: {error}",
|
||||||
|
"discordEnabled": "Discord Rich Presence aktiverad",
|
||||||
|
"discordDisabled": "Discord Rich Presence inaktiverad",
|
||||||
|
"discordSaveFailed": "Misslyckades med att spara Discord-inställning",
|
||||||
|
"playerNameRequired": "Ange ett giltigt spelarnamn",
|
||||||
|
"playerNameSaved": "Spelarnamn sparat framgångsrikt",
|
||||||
|
"playerNameSaveFailed": "Misslyckades med att spara spelarnamn",
|
||||||
|
"uuidCopied": "UUID kopierad till urklipp!",
|
||||||
|
"uuidCopyFailed": "Misslyckades med att kopiera UUID",
|
||||||
|
"uuidRegenNotAvailable": "UUID-återgenerering ej tillgänglig",
|
||||||
|
"uuidRegenFailed": "Misslyckades med att återgenerera UUID",
|
||||||
|
"uuidGenerated": "Ny UUID genererad framgångsrikt!",
|
||||||
|
"uuidGeneratedShort": "Ny UUID genererad!",
|
||||||
|
"uuidGenerateFailed": "Misslyckades med att generera ny UUID",
|
||||||
|
"uuidRequired": "Ange en UUID",
|
||||||
|
"uuidInvalidFormat": "Ogiltigt UUID-format",
|
||||||
|
"uuidSetFailed": "Misslyckades med att ange anpassad UUID",
|
||||||
|
"uuidSetSuccess": "Anpassad UUID angiven framgångsrikt!",
|
||||||
|
"uuidDeleteFailed": "Misslyckades med att ta bort UUID",
|
||||||
|
"uuidDeleteSuccess": "UUID borttagen framgångsrikt!",
|
||||||
|
"modsDownloading": "Laddar ner {name}...",
|
||||||
|
"modsTogglingMod": "Växlar modd...",
|
||||||
|
"modsDeletingMod": "Tar bort modd...",
|
||||||
|
"modsLoadingMods": "Laddar moddar från CurseForge...",
|
||||||
|
"modsInstalledSuccess": "{name} installerad framgångsrikt! 🎉",
|
||||||
|
"modsDeletedSuccess": "{name} borttagen framgångsrikt",
|
||||||
|
"modsDownloadFailed": "Misslyckades med att ladda ner modd: {error}",
|
||||||
|
"modsToggleFailed": "Misslyckades med att växla modd: {error}",
|
||||||
|
"modsDeleteFailed": "Misslyckades med att ta bort modd: {error}",
|
||||||
|
"modsModNotFound": "Moddinformation hittades inte",
|
||||||
|
"hwAccelSaved": "Hårdvaruaccelerationsinställning sparad",
|
||||||
|
"hwAccelSaveFailed": "Misslyckades med att spara hårdvaruaccelerationsinställning",
|
||||||
|
"javaPathCopied": "Java-sökväg kopierad till urklipp!",
|
||||||
|
"javaPathCopyFailed": "Misslyckades med att kopiera Java-sökväg",
|
||||||
|
"javaPathSaved": "Java-sökväg sparad framgångsrikt!",
|
||||||
|
"javaPathSaveFailed": "Misslyckades med att spara Java-sökväg",
|
||||||
|
"javaPathInvalid": "Ogiltig Java-sökväg",
|
||||||
|
"javaPathReset": "Java-sökväg återställd till standardvärden",
|
||||||
|
"gameLocationError": "Kunde inte öppna spelplats",
|
||||||
|
"launcherRestartRequired": "Launcher-omstart krävs för att tillämpa ändringar",
|
||||||
|
"gameRepairConfirm": "Är du säker på att du vill reparera spelet? Detta kommer att ominstallera alla spelfiler.",
|
||||||
|
"gameRepairInProgress": "Reparerar spel...",
|
||||||
|
"gameRepairSuccess": "Spel reparerat framgångsrikt!",
|
||||||
|
"gameRepairFailed": "Spelreparation misslyckades: {error}",
|
||||||
|
"invalidUsername": "Ogiltigt användarnamn",
|
||||||
|
"usernameInUse": "Användarnamn upptaget",
|
||||||
|
"chatJoinSuccess": "Du har gått med i chatten!",
|
||||||
|
"chatJoinFailed": "Misslyckades med att gå med i chatten",
|
||||||
|
"messageTooLong": "Meddelande för långt",
|
||||||
|
"messageSent": "Meddelande skickat",
|
||||||
|
"messageSendFailed": "Misslyckades med att skicka meddelande",
|
||||||
|
"colorUpdated": "Färg uppdaterad!",
|
||||||
|
"colorUpdateFailed": "Misslyckades med att uppdatera färg",
|
||||||
|
"profileCreated": "Profil skapad framgångsrikt!",
|
||||||
|
"profileCreateFailed": "Misslyckades med att skapa profil",
|
||||||
|
"profileDeleted": "Profil borttagen",
|
||||||
|
"profileDeleteFailed": "Misslyckades med att ta bort profil",
|
||||||
|
"profileSwitched": "Bytte profil till: {name}",
|
||||||
|
"profileSwitchFailed": "Profilbyte misslyckades",
|
||||||
|
"invalidProfileName": "Ogiltigt profilnamn",
|
||||||
|
"profileNameExists": "En profil med detta namn finns redan",
|
||||||
|
"noInternet": "Ingen internetanslutning",
|
||||||
|
"checkInternetConnection": "Kontrollera din internetanslutning",
|
||||||
|
"serverError": "Serverfel. Försök igen senare.",
|
||||||
|
"unknownError": "Ett okänt fel inträffade"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"defaultTitle": "Bekräfta åtgärd",
|
||||||
|
"regenerateUuidTitle": "Generera ny UUID",
|
||||||
|
"regenerateUuidMessage": "Är du säker på att du vill generera en ny UUID? Detta kommer att ändra din spelaridentitet.",
|
||||||
|
"regenerateUuidButton": "Generera",
|
||||||
|
"setCustomUuidTitle": "Ange anpassad UUID",
|
||||||
|
"setCustomUuidMessage": "Är du säker på att du vill ange denna anpassade UUID? Detta kommer att ändra din spelaridentitet.",
|
||||||
|
"setCustomUuidButton": "Ange UUID",
|
||||||
|
"deleteUuidTitle": "Ta bort UUID",
|
||||||
|
"deleteUuidMessage": "Är du säker på att du vill ta bort UUID:n för \"{username}\"? Denna åtgärd kan inte ångras.",
|
||||||
|
"deleteUuidButton": "Ta bort",
|
||||||
|
"uninstallGameTitle": "Avinstallera spel",
|
||||||
|
"uninstallGameMessage": "Är du säker på att du vill avinstallera Hytale? Alla spelfiler kommer att tas bort.",
|
||||||
|
"uninstallGameButton": "Avinstallera"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"initializing": "Initierar...",
|
||||||
|
"downloading": "Laddar ner...",
|
||||||
|
"installing": "Installerar...",
|
||||||
|
"extracting": "Extraherar...",
|
||||||
|
"verifying": "Verifierar...",
|
||||||
|
"switchingProfile": "Byter profil...",
|
||||||
|
"profileSwitched": "Profil bytt!",
|
||||||
|
"startingGame": "Startar spel...",
|
||||||
|
"launching": "STARTAR...",
|
||||||
|
"uninstallingGame": "Avinstallerar spel...",
|
||||||
|
"gameUninstalled": "Spel avinstallerat framgångsrikt!",
|
||||||
|
"uninstallFailed": "Avinstallation misslyckades: {error}",
|
||||||
|
"startingUpdate": "Startar obligatorisk speluppdatering...",
|
||||||
|
"installationComplete": "Installation slutförd framgångsrikt!",
|
||||||
|
"installationFailed": "Installation misslyckades: {error}",
|
||||||
|
"installingGameFiles": "Installerar spelfiler...",
|
||||||
|
"installComplete": "Installation slutförd!"
|
||||||
|
}
|
||||||
|
}
|
||||||
246
GUI/locales/tr-TR.json
Normal file
246
GUI/locales/tr-TR.json
Normal 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ı!"
|
||||||
|
}
|
||||||
|
}
|
||||||
178
GUI/splash.html
Normal file
178
GUI/splash.html
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Hytale F2P</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
text-align: center;
|
||||||
|
animation: fadeIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 0 30px rgba(147, 51, 234, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-accent {
|
||||||
|
background: linear-gradient(135deg, #9333ea, #a855f7, #c084fc);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
width: 200px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(147, 51, 234, 0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #9333ea, #a855f7, #c084fc);
|
||||||
|
animation: loading 1.5s ease-in-out infinite;
|
||||||
|
box-shadow: 0 0 20px rgba(147, 51, 234, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% {
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
animation: blink 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="background">
|
||||||
|
<img src="https://assets.authbp.xyz/bg.png" alt="Background">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="splash-container">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="./icon.png" alt="Hytale Logo">
|
||||||
|
</div>
|
||||||
|
<h1 class="title">
|
||||||
|
HY<span class="title-accent">TALE</span>
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle">FREE TO PLAY LAUNCHER</p>
|
||||||
|
<div class="loader"></div>
|
||||||
|
<p class="loading-text">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2055
GUI/style.css
2055
GUI/style.css
File diff suppressed because it is too large
Load Diff
33
PKGBUILD
33
PKGBUILD
@@ -1,31 +1,28 @@
|
|||||||
# Maintainer: Terromur <terromuroz@proton.me>
|
# Maintainer: Terromur <terromuroz@proton.me>
|
||||||
pkgname=Hytale-F2P-git
|
# Maintainer: Fazri Gading <fazrigading@gmail.com>
|
||||||
_pkgname=Hytale-F2P
|
# This PKGBUILD is for Github Releases
|
||||||
pkgver=2.0.0.r47.gebcfdc4
|
pkgname=Hytale-F2P
|
||||||
|
pkgver=2.1.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="HyLauncher - unofficial Hytale Launcher for free to play gamers"
|
pkgdesc="Hytale-F2P - unofficial Hytale Launcher for free to play with multiplayer support"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/amiayweb/Hytale-F2P"
|
url="https://github.com/amiayweb/Hytale-F2P"
|
||||||
license=('custom')
|
license=('custom')
|
||||||
|
depends=('gtk3' 'nss' 'libxcrypt-compat')
|
||||||
makedepends=('npm')
|
makedepends=('npm')
|
||||||
source=("git+$url.git" "Hytale-F2P.desktop")
|
source=("$url/archive/v$pkgver.tar.gz" "Hytale-F2P.desktop")
|
||||||
sha256sums=('SKIP' '46488fada4775d9976d7b7b62f8d1f1f8d9a9a9d8f8aa9af4f2e2153019f6a30')
|
sha256sums=('SKIP' '46488fada4775d9976d7b7b62f8d1f1f8d9a9a9d8f8aa9af4f2e2153019f6a30')
|
||||||
|
|
||||||
pkgver() {
|
|
||||||
cd "$_pkgname"
|
|
||||||
printf "2.0.0.r%s.g%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "$_pkgname"
|
cd "$pkgname-$pkgver"
|
||||||
npm install
|
npm ci
|
||||||
npm run build:linux
|
npm run build:arch
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
mkdir -p "$pkgdir/opt/$_pkgname"
|
cd "$pkgname-$pkgver"
|
||||||
cp -r "$_pkgname/dist/linux-unpacked/"* "$pkgdir/opt/$_pkgname"
|
install -d "$pkgdir/opt/$pkgname"
|
||||||
install -Dm644 "$_pkgname.desktop" "$pkgdir/usr/share/applications/$_pkgname.desktop"
|
cp -r dist/linux-unpacked/* "$pkgdir/opt/$pkgname"
|
||||||
install -Dm644 "$_pkgname/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/$_pkgname.png"
|
install -Dm644 "$srcdir/$pkgname.desktop" "$pkgdir/usr/share/applications/$pkgname.desktop"
|
||||||
|
install -Dm644 GUI/icon.png "$pkgdir/usr/share/icons/hicolor/256x256/apps/$pkgname.png"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
PKGBUILD-git
Normal file
34
PKGBUILD-git
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Maintainer: Terromur <terromuroz@proton.me>
|
||||||
|
# Maintainer: Fazri Gading <fazrigading@gmail.com>
|
||||||
|
pkgname=Hytale-F2P-git
|
||||||
|
_pkgname=Hytale-F2P
|
||||||
|
pkgver=0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Hytale-F2P - Unofficial Hytale Launcher for free to play with multiplayer support (rolling git build)"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/amiayweb/Hytale-F2P"
|
||||||
|
license=('custom')
|
||||||
|
depends=('gtk3' 'nss' 'libxcrypt-compat')
|
||||||
|
makedepends=('git' 'npm')
|
||||||
|
source=("git+$url.git" "$_pkgname.desktop")
|
||||||
|
sha256sums=('SKIP' '46488fada4775d9976d7b7b62f8d1f1f8d9a9a9d8f8aa9af4f2e2153019f6a30')
|
||||||
|
|
||||||
|
pkgver() {
|
||||||
|
cd "$srcdir/$_pkgname"
|
||||||
|
git describe --tags --long | sed 's/^v//;s/-/.r/;s/-/./'
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$srcdir/$_pkgname"
|
||||||
|
npm ci
|
||||||
|
npm run build:arch
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$srcdir/$_pkgname"
|
||||||
|
install -d "$pkgdir/opt/$_pkgname"
|
||||||
|
cp -r "$_pkgname/dist/linux-unpacked/"* "$pkgdir/opt/$_pkgname"
|
||||||
|
|
||||||
|
install -Dm644 "$srcdir/$_pkgname.desktop" "$pkgdir/usr/share/applications/$_pkgname.desktop"
|
||||||
|
install -Dm644 GUI/icon.png "$pkgdir/usr/share/icons/hicolor/256x256/apps/$_pkgname.png"
|
||||||
|
}
|
||||||
352
README.md
352
README.md
@@ -1,18 +1,33 @@
|
|||||||
# 🎮 Hytale F2P Launcher | Multiplayer Support
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|
<header>
|
||||||

|
<h1>🎮 Hytale F2P Launcher 🚀</h1>
|
||||||

|
<h2>💻 Cross-Platform Multiplayer 🖥️</h2>
|
||||||
|
<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 offline launcher for Hytale with automatic updates and multiplayer support (windows users & non-premium only)**
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
[](https://github.com/amiayweb/Hytale-F2P/stargazers)
|
[](https://github.com/amiayweb/Hytale-F2P/stargazers)
|
||||||
[](https://github.com/amiayweb/Hytale-F2P/network/members)
|
[](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? Open an issue! I’m on Windows, so I can’t test on macOS or Linux.** 🛑
|
|
||||||
|
### ⚠️ **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> ☕<br>
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -20,12 +35,42 @@
|
|||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -36,7 +81,7 @@
|
|||||||
- 💾 **Data Preservation** - Intelligent UserData backup and restoration during updates
|
- 💾 **Data Preservation** - Intelligent UserData backup and restoration during updates
|
||||||
- 🌐 **Cross-Platform** - Full support for Windows, Linux (X11/Wayland), and macOS
|
- 🌐 **Cross-Platform** - Full support for Windows, Linux (X11/Wayland), and macOS
|
||||||
- ☕ **Java Management** - Automatic Java runtime detection and installation
|
- ☕ **Java Management** - Automatic Java runtime detection and installation
|
||||||
- 🎮 **Multiplayer Support** - Automatic multiplayer client installation (Windows)
|
- 🎮 **Multiplayer Support** - Automatic multiplayer client installation (Windows, macOS & Linux !)
|
||||||
|
|
||||||
🛡️ **Advanced Features**
|
🛡️ **Advanced Features**
|
||||||
- 📁 **Custom Installation** - Choose your own installation directory
|
- 📁 **Custom Installation** - Choose your own installation directory
|
||||||
@@ -48,36 +93,259 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start
|
# 🚀 Quick Start
|
||||||
|
|
||||||
### 📥 Installation
|
## 🖥️ System Requirements
|
||||||
|
|
||||||
#### Windows
|
### 🎮 Hytale Hardware Requirements
|
||||||
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
|
|
||||||
|
|
||||||
#### Linux
|
> [!IMPORTANT]
|
||||||
See [BUILD.md](BUILD.md) for detailed build instructions or [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases) section.
|
> 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
|
<div align="center">
|
||||||
See [BUILD.md](BUILD.md) for detailed build instructions or [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases) section.
|
|
||||||
|
|
||||||
#### 🖥️ How to create server (Windows Only)?
|
<table>
|
||||||
See [SERVER.md](SERVER.md)
|
<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 <sup><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 (dGPU)<sup><a href="#fn1" id="ref2">2</a></sup> /<br>12GB (iGPU)<sup><a href="#fn1" id="ref3">3</a></sup></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>Note 1</sup> Hytale did not provide game files for macOS Intel, yet.</p>
|
||||||
|
<p id="fn2"><sup>Note 2</sup> Using Discrete/Dedicated GPU (dGPU) must have 8 GB RAM minimum.</p>
|
||||||
|
<p id="fn3"><sup>Note 3</sup> Using Integrated GPU (dGPU) must have 12 GB RAM minimum.</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 🪟 Windows Prequisites
|
||||||
|
* **
|
||||||
|
* **Java JDK 25:**
|
||||||
|
* [Oracle](https://www.oracle.com/java/technologies/downloads/#jdk25-windows), **no** support for Windows ARM64 in both version 25 and 21.
|
||||||
|
* [Adoptium](https://adoptium.net/temurin/releases/?version=25), has Windows ARM64 support in version 21 only.
|
||||||
|
* [Microsoft](https://learn.microsoft.com/en-us/java/openjdk/download), has Windows ARM64 support in version 25.
|
||||||
|
* Download from any vendor if your OS is not Windows with ARM64 architecture.
|
||||||
|
* **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/)
|
||||||
|
|
||||||
|
### 🐧 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
|
||||||
|
```
|
||||||
|
* **Ubuntu/Debian-based or Fedora/RHEL-based:** Install the DEB/RPM:
|
||||||
|
```bash
|
||||||
|
# Fedora/RHEL-based
|
||||||
|
sudo dnf install hytale-f2p-launcher.rpm
|
||||||
|
# Debian/Ubuntu
|
||||||
|
sudo apt install -y libasound2 libpng16-16 libpng-dev libicu76
|
||||||
|
sudo dpkg -i hytale-f2p-launcher.deb
|
||||||
|
```
|
||||||
|
* **Arch Linux (pacman):** Install the package using:
|
||||||
|
```bash
|
||||||
|
# Stable Build
|
||||||
|
sudo pacman -U hytale-f2p-launcher.pkg.tar.zst
|
||||||
|
# Development Build
|
||||||
|
yay -S hytale-f2p-git # or
|
||||||
|
paru -S hytale-f2p-git
|
||||||
|
# Manual Build
|
||||||
|
git clone https://aur.archlinux.org/hytale-f2p-git.git
|
||||||
|
cd hytale-f2p-git
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Make sure to adjust the filename correctly with the version and the architecture type. TIP: Use `cd` command to the package location.
|
||||||
|
|
||||||
|
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.
|
||||||
|
* Missing libxcrypt.so.1: Install `libxcrypt-compat` using your package manager
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🍎 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
|
## 🛠️ Building from Source
|
||||||
|
|
||||||
See [BUILD.md](BUILD.md) for comprehensive build instructions.
|
See [BUILD.md](docs/BUILD.md) for comprehensive build instructions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Changelog
|
## 📋 Changelog
|
||||||
|
|
||||||
### 🆕 v2.0.1 *(Latest)*
|
### 🆕 v2.1.1
|
||||||
|
- 🛠️ **Fix Bug EPERM**: EPERM or Error Permission in creating/removing process in reinstalling is now fixed.
|
||||||
|
- 🅰️ **Adds .pkg.tar.zst Build for Arch Users**: This Arch-package has been needed since the first release.
|
||||||
|
- ❎ **Removes .pacman Build for Arch**: Based on the established conventions within the Arch Linux community, the file extension .pacman should not be used for package files.
|
||||||
|
- 🌎 **New Translation**: New Polish 🇵🇱 Translation added to the Launcher.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click here to see older Changelogs</summary>
|
||||||
|
|
||||||
|
### 🔄 v2.1.0
|
||||||
|
- 🚨 **Auto-Retry Downloads and Auto-Patch Files** —
|
||||||
|
- ⚡ **Hardware Acceleration** —
|
||||||
|
- 🔎 **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.
|
||||||
|
|
||||||
|
### 🔄 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*.
|
||||||
|
- 👨💻 **In-App Logging** — Reporting bugs and issues to `Github Issues` tab or `Open A Ticket` channel in our Discord Server has been made easier for players, no more finding logs file manually.
|
||||||
|
- 🛠️ **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)*
|
||||||
|
- 🧑🚀 **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`.
|
||||||
|
- 🛡️ **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
|
||||||
|
- 🎮 **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
|
||||||
|
- 🏆 **Badge System Expansion** - Added new FOUNDER UUID to the badge system
|
||||||
|
- 🔧 **Progress Bar Fix** - Resolved issue where download progress bar stayed active after game launch
|
||||||
|
- 🐛 **Bug Fixes**: General fixes
|
||||||
|
|
||||||
|
### 🔄 v2.0.1
|
||||||
- 📊 **Advanced Logging System** - Complete logging with timestamps, file rotation, and session tracking
|
- 📊 **Advanced Logging System** - Complete logging with timestamps, file rotation, and session tracking
|
||||||
- 🔧 **Play Button Fix** - Resolved issue where play button could get stuck in "CHECKING..." state
|
- 🔧 **Play Button Fix** - Resolved issue where play button could get stuck in "CHECKING..." state
|
||||||
- 💬 **Discord Integration** - Added closable Discord notification for community engagement
|
- 💬 **Discord Integration** - Added closable Discord notification for community engagement
|
||||||
@@ -114,7 +382,7 @@ See [BUILD.md](BUILD.md) for comprehensive build instructions.
|
|||||||
- ☕ **Java Management** - Automatic Java runtime handling
|
- ☕ **Java Management** - Automatic Java runtime handling
|
||||||
- 🎨 **Modern Interface** - Clean, intuitive design
|
- 🎨 **Modern Interface** - Clean, intuitive design
|
||||||
- 🌟 **First Release** - Core launcher functionality
|
- 🌟 **First Release** - Core launcher functionality
|
||||||
|
</details>
|
||||||
---
|
---
|
||||||
|
|
||||||
## 👥 Contributors
|
## 👥 Contributors
|
||||||
@@ -128,15 +396,19 @@ See [BUILD.md](BUILD.md) for comprehensive build instructions.
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
### 🏆 Project Creator
|
### 🏆 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
|
### 🌟 Contributors
|
||||||
|
- [**@sanasol**](https://github.com/sanasol) - *Main Issues fixer | Multiplayer Patcher*
|
||||||
|
- [**@Terromur**](https://github.com/Terromur) - *Main Issues fixer | Beta tester*
|
||||||
|
- [**@fazrigading**](https://github.com/fazrigading) - *Main Issues fixer | Beta tester*
|
||||||
|
- [**@ericiskoolbeans**](https://github.com/ericiskoolbeans) - *Beta Tester*
|
||||||
- [**@chasem-dev**](https://github.com/chasem-dev) - *Issues fixer*
|
- [**@chasem-dev**](https://github.com/chasem-dev) - *Issues fixer*
|
||||||
- [**@crimera**](https://github.com/crimera) - *Issues fixer*
|
- [**@crimera**](https://github.com/crimera) - *Issues fixer*
|
||||||
- [**@sanasol**](https://github.com/sanasol) - *Issues fixer*
|
|
||||||
- [**@Terromur**](https://github.com/Terromur) - *Issues fixer*
|
|
||||||
- [**@Citeli-py**](https://github.com/Citeli-py) - *Issues fixer*
|
- [**@Citeli-py**](https://github.com/Citeli-py) - *Issues fixer*
|
||||||
- [**@ericiskoolbeans**](https://github.com/ericiskoolbeans) - *Beta Tester*
|
- [**@Rahul-Sahani04**](https://github.com/Rahul-Sahani04) - *Issues fixer*
|
||||||
|
- [**@xSamiVS**](https://github.com/xSamiVS) - *Language Translator*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -156,10 +428,7 @@ See [BUILD.md](BUILD.md) for comprehensive build instructions.
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/amiayweb/Hytale-F2P/issues)
|
**Need help?** Join us: https://discord.gg/gME8rUy3MB
|
||||||
[](https://github.com/amiayweb/Hytale-F2P/discussions)
|
|
||||||
|
|
||||||
**Need help?** Open an [issue](https://github.com/amiayweb/Hytale-F2P/issues) or start a [discussion](https://github.com/amiayweb/Hytale-F2P/discussions)!
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -183,7 +452,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.
|
🛑 **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -191,7 +460,8 @@ This launcher is created for **educational purposes only**.
|
|||||||
|
|
||||||
**⭐ Star this project if you found it helpful! ⭐**
|
**⭐ 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*
|
||||||
|
|
||||||
[](https://www.star-history.com/#amiayweb/Hytale-F2P&type=date&legend=top-left)
|
[](https://www.star-history.com/#amiayweb/Hytale-F2P&type=date&legend=top-left)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
488
SERVER.md
488
SERVER.md
@@ -1,87 +1,459 @@
|
|||||||
# Hytale F2P Server Setup Guide
|
# Hytale F2P Server Guide
|
||||||
|
|
||||||
## Server File Setup
|
Play with friends online! This guide covers both easy in-game hosting and advanced dedicated server setup.
|
||||||
|
|
||||||
**Download server file:**
|
DOWNLOAD SERVER FILES HERE: https://discord.gg/MEyWUxt77m
|
||||||
```
|
|
||||||
http://3.10.208.30:3002/server
|
|
||||||
```
|
|
||||||
|
|
||||||
**Replace the file here:**
|
|
||||||
`<your_path>\HytaleF2P\release\package\game\latest\Server`
|
|
||||||
|
|
||||||
If you don't have any custom installation path:
|
|
||||||
|
|
||||||
1. Press **WIN + R**
|
|
||||||
2. Type: `%localappdata%\HytaleF2P\release\package\game\latest\Server`
|
|
||||||
3. Press **Enter**
|
|
||||||
|
|
||||||
You will be redirected to the correct folder automatically.
|
|
||||||
|
|
||||||
## Network Setup - Radmin VPN Required
|
|
||||||
|
|
||||||
**Important:** The server only supports third-party software for LAN-style connections. You must use **Radmin VPN** to connect players together.
|
|
||||||
|
|
||||||
1. **Download and install [Radmin VPN](https://www.radmin-vpn.com/)**
|
|
||||||
2. **Create or join a network** in Radmin VPN
|
|
||||||
3. **All players must be connected** to the same Radmin network
|
|
||||||
4. **Use the Radmin VPN IP address** to connect to the server
|
|
||||||
|
|
||||||
This creates a virtual LAN environment that allows the Hytale server to work properly with multiple players.
|
|
||||||
|
|
||||||
## RAM Allocation Guide (Windows)
|
|
||||||
|
|
||||||
When you start a Hytale server using `start-server.bat`, Java will use very little memory by default.
|
|
||||||
This can cause slow startup, crashes, or the server not launching at all.
|
|
||||||
|
|
||||||
**You should always allocate RAM in your launch command.**
|
|
||||||
|
|
||||||
Edit your `start-server.bat` file and use the version that matches your PC:
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### PC with 4 GB RAM
|
## Part 1: Playing with Friends (Online Play)
|
||||||
*Best for small servers / testing*
|
|
||||||
|
The easiest way to play with friends - no manual server setup required!
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Start the game** via F2P Launcher
|
||||||
|
2. **Click "Online Play"** in the main menu
|
||||||
|
3. **Share the invite code** with your friends
|
||||||
|
4. Friends enter your invite code to join
|
||||||
|
|
||||||
|
The game automatically handles networking using UPnP/STUN/NAT traversal.
|
||||||
|
|
||||||
|
### Network Requirements
|
||||||
|
|
||||||
|
For Online Play to work, you need:
|
||||||
|
|
||||||
|
- **UPnP enabled** on your router (most routers have this on by default)
|
||||||
|
- **Public IP address** from your ISP (not behind CGNAT)
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### "NAT Type: Carrier-Grade NAT (CGNAT)" Warning
|
||||||
|
|
||||||
|
If you see this message:
|
||||||
|
```
|
||||||
|
Connected via UPnP
|
||||||
|
NAT Type: Carrier-Grade NAT (CGNAT)
|
||||||
|
Warning: Your network configuration may prevent other players from connecting.
|
||||||
|
```
|
||||||
|
|
||||||
|
**What this means:** Your ISP doesn't give you a public IP address. Multiple customers share one public IP, which blocks incoming connections.
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
|
||||||
|
1. **Contact your ISP** - Request a public/static IP address (may cost extra)
|
||||||
|
2. **Use a VPN with port forwarding** - Services like Mullvad, PIA, or AirVPN offer this
|
||||||
|
3. **Use Radmin VPN or Playit.gg** - Create a virtual LAN with friends (see below)
|
||||||
|
4. **Have a friend with public IP host instead**
|
||||||
|
|
||||||
|
#### "UPnP Failed" or "Port Mapping Failed"
|
||||||
|
|
||||||
|
**Check your router:**
|
||||||
|
1. Log into router admin panel (usually `192.168.1.1` or `192.168.0.1`)
|
||||||
|
2. Find UPnP settings (often under "Advanced" or "NAT")
|
||||||
|
3. Enable UPnP if disabled
|
||||||
|
4. Restart your router
|
||||||
|
|
||||||
|
**If UPnP isn't available:**
|
||||||
|
- Manually forward **port 5520 UDP** to your computer's local IP
|
||||||
|
- See "Port Forwarding" section below
|
||||||
|
|
||||||
|
#### "Strict NAT" or "Symmetric NAT"
|
||||||
|
|
||||||
|
Some routers have restrictive NAT that blocks peer connections.
|
||||||
|
|
||||||
|
**Try:**
|
||||||
|
1. Enable "NAT Passthrough" or "NAT Filtering: Open" in router settings
|
||||||
|
2. Put your device in router's DMZ (temporary test only)
|
||||||
|
3. Use Radmin VPN as workaround
|
||||||
|
|
||||||
|
### Workarounds for NAT/CGNAT Issues
|
||||||
|
|
||||||
|
#### Option 1: playit.gg (Recommended)
|
||||||
|
|
||||||
|
Free tunneling service - only the host needs to install it:
|
||||||
|
|
||||||
|
1. **Download [playit.gg](https://playit.gg/)** and run it - Connect your account from the terminal (do not close it when playing on the server)
|
||||||
|
2. **Add a tunnel** - Select "UDP", tunnel description of "Hytale Server", port count `1`, and local port `5520`
|
||||||
|
3. **Start the tunnel** - You'll get a public address like `xx-xx.gl.at.ply.gg:5520`
|
||||||
|
4. **Share the address** - Friends connect directly using this address
|
||||||
|
|
||||||
|
Works with both Online Play and dedicated servers. No software needed for players joining.
|
||||||
|
|
||||||
|
#### Option 2: Radmin VPN
|
||||||
|
|
||||||
|
Creates a virtual LAN - all players need to install it:
|
||||||
|
|
||||||
|
1. **Download [Radmin VPN](https://www.radmin-vpn.com/)** - All players install it
|
||||||
|
2. **Create a network** - One person creates, others join with network name/password
|
||||||
|
3. **Host via Online Play** - Use your Radmin VPN IP instead
|
||||||
|
4. **Friends connect** - They'll see you on the virtual LAN
|
||||||
|
|
||||||
|
Both options bypass all NAT/CGNAT issues. But for **Windows machines only!**
|
||||||
|
|
||||||
|
#### Option 3: Tailscale
|
||||||
|
It creates mesh VPN service that streamlines connecting devices and services securely across different networks. And **works crossplatform!!**
|
||||||
|
|
||||||
|
1. All member's are required to download [Tailscale](https://tailscale.com/download) on your device.
|
||||||
|
[Once installed, Tailwind starts and live inside your hidden icon section in Windows, Mac and Linux]
|
||||||
|
2. Create a **common tailscale** account which will shared among your friends to log in.
|
||||||
|
3. Ask your **host to login in to thier tailscale client first**, then the other members.
|
||||||
|
##### Host
|
||||||
|
1. Open your singleplayer world
|
||||||
|
2. Go to Online Play settings
|
||||||
|
3. Re-save your settings to generate a new share code
|
||||||
|
##### Friends
|
||||||
|
1. Ensure Tailscale is connected
|
||||||
|
2. Use the new share code to connect
|
||||||
|
[To test your connection, ping the host's ipv4 mentioned in tailwind]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: Dedicated Server (Advanced)
|
||||||
|
|
||||||
|
For 24/7 servers, custom configurations, or hosting on a VPS/dedicated machine.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
#### Step 1: Get the Server JAR
|
||||||
|
|
||||||
|
The server scripts will automatically download the pre-patched server JAR if it's not present.
|
||||||
|
|
||||||
|
**Option A:** Let the scripts download automatically (requires `HYTALE_SERVER_URL` to be configured)
|
||||||
|
|
||||||
|
**Option B:** Manually place `HytaleServer.jar` (pre-patched for F2P) in the Server directory:
|
||||||
|
|
||||||
|
- **Windows:** `%localappdata%\HytaleF2P\release\package\game\latest\Server`
|
||||||
|
- **macOS:** `~/Library/Application Support/HytaleF2P/release/package/game/latest/Server`
|
||||||
|
- **Linux:** `~/.hytalef2p/release/package/game/latest/Server`
|
||||||
|
|
||||||
|
If you have a custom install path, the Server folder is inside your custom location under `HytaleF2P/release/package/game/latest/Server`.
|
||||||
|
|
||||||
|
#### Step 2: Run the Server
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
```batch
|
||||||
|
cd scripts
|
||||||
|
run_server.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS / Linux:**
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
./run_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The scripts will:
|
||||||
|
1. Find your game installation automatically
|
||||||
|
2. Download the pre-patched server JAR if needed
|
||||||
|
3. Fetch session tokens from the auth server
|
||||||
|
4. Start the server
|
||||||
|
|
||||||
|
#### Step 3: Connect Players
|
||||||
|
|
||||||
|
Share your server IP address with players. They connect via the F2P Launcher's server browser or direct connect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Network Setup (Dedicated Server)
|
||||||
|
|
||||||
|
### Local Network (LAN)
|
||||||
|
|
||||||
|
If all players are on the same network:
|
||||||
|
1. Find your local IP: `ipconfig` (Windows) or `ifconfig` (Mac/Linux)
|
||||||
|
2. Share this IP with players on your network
|
||||||
|
3. Default port is `5520`
|
||||||
|
|
||||||
|
### Port Forwarding (Internet Play)
|
||||||
|
|
||||||
|
To allow direct internet connections:
|
||||||
|
|
||||||
|
1. Forward **port 5520 (UDP)** in your router
|
||||||
|
2. Find your public IP at [whatismyip.com](https://whatismyip.com)
|
||||||
|
3. Share your public IP with players
|
||||||
|
|
||||||
|
**Windows Firewall:**
|
||||||
|
```powershell
|
||||||
|
# Run as Administrator
|
||||||
|
netsh advfirewall firewall add rule name="Hytale Server" dir=in action=allow protocol=UDP localport=5520
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
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` | `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 |
|
||||||
|
| `HYTALE_GAME_PATH` | (auto-detected) | Override game location |
|
||||||
|
| `JVM_XMS` | `2G` | Minimum Java memory |
|
||||||
|
| `JVM_XMX` | `4G` | Maximum Java memory |
|
||||||
|
|
||||||
|
**Example (Windows):**
|
||||||
|
```batch
|
||||||
|
set HYTALE_SERVER_NAME=My Awesome Server
|
||||||
|
set JVM_XMX=8G
|
||||||
|
run_server.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (Linux/macOS):**
|
||||||
|
```bash
|
||||||
|
HYTALE_SERVER_NAME="My Awesome Server" JVM_XMX=8G ./run_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Modes
|
||||||
|
|
||||||
|
| Mode | Description | Use Case |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| `authenticated` | Players log in via F2P Launcher | Public servers |
|
||||||
|
| `unauthenticated` | No login required | LAN parties, testing |
|
||||||
|
| `singleplayer` | Local play only | Solo testing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RAM Allocation Guide
|
||||||
|
|
||||||
|
Adjust memory based on your system:
|
||||||
|
|
||||||
|
| System RAM | Players | JVM_XMS | JVM_XMX |
|
||||||
|
|------------|---------|---------|---------|
|
||||||
|
| 4 GB | 1-3 | `512M` | `2G` |
|
||||||
|
| 8 GB | 3-8 | `1G` | `4G` |
|
||||||
|
| 16 GB | 8-15 | `2G` | `8G` |
|
||||||
|
| 32 GB | 15+ | `4G` | `12G` |
|
||||||
|
|
||||||
|
**Example for large server:**
|
||||||
|
```bash
|
||||||
|
JVM_XMS=4G JVM_XMX=12G ./run_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tips:**
|
||||||
|
- `-Xms` = minimum RAM (allocated at startup)
|
||||||
|
- `-Xmx` = maximum RAM (upper limit)
|
||||||
|
- Never allocate all your system RAM - leave room for OS
|
||||||
|
- Start conservative and increase if needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Commands
|
||||||
|
|
||||||
|
Once running, use these commands in the console:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `help` | Show all commands |
|
||||||
|
| `stop` | Stop server gracefully |
|
||||||
|
| `save` | Force world save |
|
||||||
|
| `list` | List online players |
|
||||||
|
| `op <player>` | Give operator status |
|
||||||
|
| `deop <player>` | Remove operator status |
|
||||||
|
| `kick <player>` | Kick a player |
|
||||||
|
| `ban <player>` | Ban a player |
|
||||||
|
| `unban <player>` | Unban a player |
|
||||||
|
| `tp <player> <x> <y> <z>` | Teleport player |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command Line Options
|
||||||
|
|
||||||
|
Pass these when starting the server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -Xms512M -Xmx2G -jar HytaleServer.jar --assets ..\Assets.zip
|
./run_server.sh [OPTIONS]
|
||||||
```
|
```
|
||||||
|
|
||||||
- Uses up to **2 GB**
|
| Option | Description |
|
||||||
- Leaves enough memory for Windows
|
|--------|-------------|
|
||||||
|
| `--bind <ip:port>` | Server address (default: 0.0.0.0:5520) |
|
||||||
|
| `--auth-mode <mode>` | Authentication mode |
|
||||||
|
| `--universe <path>` | Path to world data |
|
||||||
|
| `--mods <path>` | Path to mods folder |
|
||||||
|
| `--backup` | Enable automatic backups |
|
||||||
|
| `--backup-dir <path>` | Backup directory |
|
||||||
|
| `--backup-frequency <mins>` | Backup interval |
|
||||||
|
| `--owner-name <name>` | Server owner username |
|
||||||
|
| `--allow-op` | Allow op commands |
|
||||||
|
| `--disable-sentry` | Disable crash reporting |
|
||||||
|
| `--help` | Show all options |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
./run_server.sh --backup --backup-frequency 30 --allow-op
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### PC with 8 GB RAM
|
## File Structure
|
||||||
*Good for small communities*
|
|
||||||
|
```
|
||||||
|
<game_path>/
|
||||||
|
├── Assets.zip # Game assets (required)
|
||||||
|
├── Client/ # Game client
|
||||||
|
└── Server/
|
||||||
|
├── HytaleServer.jar # Server executable (pre-patched)
|
||||||
|
├── HytaleServer.aot # AOT cache (faster startup)
|
||||||
|
├── universe/ # World data
|
||||||
|
│ ├── world/ # Default world
|
||||||
|
│ └── players/ # Player data
|
||||||
|
├── mods/ # Server mods (optional)
|
||||||
|
└── Licenses/ # License files
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
### Automatic Backups
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -Xms1G -Xmx4G -jar HytaleServer.jar --assets ..\Assets.zip
|
./run_server.sh --backup --backup-dir ./backups --backup-frequency 30
|
||||||
```
|
```
|
||||||
|
|
||||||
- Uses up to **4 GB**
|
### Manual Backup
|
||||||
- Stable for most setups
|
|
||||||
|
1. Use `save` command or stop the server
|
||||||
|
2. Copy the `universe/` folder
|
||||||
|
3. Store in a safe location
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
1. Stop the server
|
||||||
|
2. Delete/rename current `universe/`
|
||||||
|
3. Copy backup to `universe/`
|
||||||
|
4. Restart server
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### PC with 16 GB RAM
|
## Troubleshooting
|
||||||
*Perfect for large or modded servers*
|
|
||||||
|
### "Java not found" or "Java version too old"
|
||||||
|
|
||||||
|
**Java 21 is REQUIRED** (the server uses class file version 65.0).
|
||||||
|
|
||||||
|
**Install Java 21:**
|
||||||
|
- **Windows:** `winget install EclipseAdoptium.Temurin.21.JDK`
|
||||||
|
- **macOS:** `brew install openjdk@21`
|
||||||
|
- **Ubuntu:** `sudo apt install openjdk-21-jdk`
|
||||||
|
- **Fedora:** `sudo dnf install java-21-openjdk`
|
||||||
|
- **Arch:** `sudo pacman -S jdk21-openjdk`
|
||||||
|
- **Download:** [adoptium.net/temurin/releases/?version=21](https://adoptium.net/temurin/releases/?version=21)
|
||||||
|
|
||||||
|
**macOS: Set Java 21 as default:**
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
```
|
||||||
|
Add these lines to `~/.zshrc` or `~/.bash_profile` to make permanent.
|
||||||
|
|
||||||
|
### "Game directory not found"
|
||||||
|
|
||||||
|
- Download game via F2P Launcher first
|
||||||
|
- Or set `HYTALE_GAME_PATH` environment variable
|
||||||
|
- Check custom install path in launcher settings
|
||||||
|
|
||||||
|
### "Assets.zip not found"
|
||||||
|
|
||||||
|
Game files incomplete. Re-download via the launcher.
|
||||||
|
|
||||||
|
### "Port already in use"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -Xms2G -Xmx8G -jar HytaleServer.jar --assets ..\Assets.zip
|
./run_server.sh --bind 0.0.0.0:5521
|
||||||
```
|
```
|
||||||
|
|
||||||
- Uses up to **8 GB**
|
### "Out of memory"
|
||||||
- Ideal for heavy worlds and plugins
|
|
||||||
|
Increase JVM_XMX:
|
||||||
|
```bash
|
||||||
|
JVM_XMX=6G ./run_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Players can't connect
|
||||||
|
|
||||||
|
1. Server shows "Server Ready"?
|
||||||
|
2. Using F2P Launcher (not official)?
|
||||||
|
3. Port 5520 open in firewall?
|
||||||
|
4. Port forwarding configured (for internet)?
|
||||||
|
5. Try `--auth-mode unauthenticated` for testing
|
||||||
|
|
||||||
|
### "Authentication failed"
|
||||||
|
|
||||||
|
- Ensure players use F2P Launcher
|
||||||
|
- Auth server may be temporarily down
|
||||||
|
- Test with `--auth-mode unauthenticated`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tips
|
## Docker Deployment (Advanced)
|
||||||
|
|
||||||
- `-Xms` = minimum RAM allocation
|
For production servers, use Docker:
|
||||||
- `-Xmx` = maximum RAM allocation
|
|
||||||
- **Never allocate all your system RAM** — Windows still needs memory to run
|
|
||||||
- **Test your configuration** with a small world first
|
|
||||||
- **Monitor server performance** and adjust RAM as needed
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name hytale-server \
|
||||||
|
-p 5520:5520/udp \
|
||||||
|
-v ./data:/data \
|
||||||
|
-e HYTALE_AUTH_DOMAIN=auth.sanasol.ws \
|
||||||
|
-e HYTALE_SERVER_NAME="My Server" \
|
||||||
|
-e JVM_XMX=8G \
|
||||||
|
ghcr.io/hybrowse/hytale-server-docker:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Docker documentation](https://github.com/Hybrowse/hytale-server-docker) for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Settings Summary
|
||||||
|
|
||||||
|
### Minimal Setup
|
||||||
|
```bash
|
||||||
|
./run_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Memory
|
||||||
|
```bash
|
||||||
|
JVM_XMS=2G JVM_XMX=8G ./run_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Port
|
||||||
|
```bash
|
||||||
|
HYTALE_BIND=0.0.0.0:25565 ./run_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### LAN Party (No Auth)
|
||||||
|
```bash
|
||||||
|
./run_server.sh --auth-mode unauthenticated
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Custom Setup
|
||||||
|
```bash
|
||||||
|
HYTALE_SERVER_NAME="Epic Server" \
|
||||||
|
HYTALE_BIND=0.0.0.0:5520 \
|
||||||
|
JVM_XMS=2G \
|
||||||
|
JVM_XMX=8G \
|
||||||
|
./run_server.sh --backup --backup-frequency 15 --allow-op
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
- Check server console logs for errors
|
||||||
|
- Test with `--auth-mode unauthenticated` first
|
||||||
|
- Ensure all players have F2P Launcher
|
||||||
|
- Join the community for support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
- Hytale F2P Project
|
||||||
|
- [Hybrowse Docker Image](https://github.com/Hybrowse/hytale-server-docker)
|
||||||
|
- Auth Server: sanasol.ws
|
||||||
|
|||||||
373
backend/appUpdater.js
Normal file
373
backend/appUpdater.js
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
const { autoUpdater } = require('electron-updater');
|
||||||
|
const { app } = require('electron');
|
||||||
|
const logger = require('./logger');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
class AppUpdater {
|
||||||
|
constructor(mainWindow) {
|
||||||
|
this.mainWindow = mainWindow;
|
||||||
|
this.autoUpdateAvailable = true; // Track if auto-update is possible
|
||||||
|
this.updateAvailable = false; // Track if an update was detected
|
||||||
|
this.updateVersion = null; // Store the available update version
|
||||||
|
this.setupAutoUpdater();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupAutoUpdater() {
|
||||||
|
|
||||||
|
// Configure logger for electron-updater
|
||||||
|
// Create a compatible logger interface
|
||||||
|
autoUpdater.logger = {
|
||||||
|
info: (...args) => logger.info(...args),
|
||||||
|
warn: (...args) => logger.warn(...args),
|
||||||
|
error: (...args) => logger.error(...args),
|
||||||
|
debug: (...args) => logger.log(...args)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto download updates
|
||||||
|
autoUpdater.autoDownload = true;
|
||||||
|
// Auto install on quit (after download)
|
||||||
|
autoUpdater.autoInstallOnAppQuit = true;
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
autoUpdater.on('checking-for-update', () => {
|
||||||
|
console.log('Checking for updates...');
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('update-checking');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('update-available', (info) => {
|
||||||
|
console.log('Update available:', info.version);
|
||||||
|
const currentVersion = app.getVersion();
|
||||||
|
const newVersion = info.version;
|
||||||
|
|
||||||
|
// Only proceed if the new version is actually different from current
|
||||||
|
if (newVersion === currentVersion) {
|
||||||
|
console.log('Update version matches current version, ignoring update-available event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateAvailable = true;
|
||||||
|
this.updateVersion = newVersion;
|
||||||
|
this.autoUpdateAvailable = true; // Reset flag when new update is available
|
||||||
|
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('update-available', {
|
||||||
|
version: newVersion,
|
||||||
|
newVersion: newVersion,
|
||||||
|
currentVersion: currentVersion,
|
||||||
|
releaseName: info.releaseName,
|
||||||
|
releaseNotes: info.releaseNotes
|
||||||
|
});
|
||||||
|
// Also send to the old popup handler for compatibility
|
||||||
|
this.mainWindow.webContents.send('show-update-popup', {
|
||||||
|
currentVersion: currentVersion,
|
||||||
|
newVersion: newVersion,
|
||||||
|
version: newVersion
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('update-not-available', (info) => {
|
||||||
|
console.log('Update not available. Current version is latest.');
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('update-not-available', {
|
||||||
|
version: info.version
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('error', (err) => {
|
||||||
|
console.error('Error in auto-updater:', err);
|
||||||
|
|
||||||
|
// Check if this is a network error (not critical, don't show UI)
|
||||||
|
const errorMessage = err.message?.toLowerCase() || '';
|
||||||
|
const isNetworkError = errorMessage.includes('err_name_not_resolved') ||
|
||||||
|
errorMessage.includes('network') ||
|
||||||
|
errorMessage.includes('connection') ||
|
||||||
|
errorMessage.includes('timeout') ||
|
||||||
|
errorMessage.includes('enotfound');
|
||||||
|
|
||||||
|
if (isNetworkError) {
|
||||||
|
console.warn('Network error in auto-updater - will retry later. Not showing error UI.');
|
||||||
|
return; // Don't show error UI for network issues
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SHA512 checksum mismatch - this can happen during updates, just retry
|
||||||
|
const isChecksumError = err.code === 'ERR_CHECKSUM_MISMATCH' ||
|
||||||
|
errorMessage.includes('sha512') ||
|
||||||
|
errorMessage.includes('checksum') ||
|
||||||
|
errorMessage.includes('mismatch');
|
||||||
|
|
||||||
|
if (isChecksumError) {
|
||||||
|
console.warn('SHA512 checksum mismatch detected - clearing cache and will retry automatically. This is normal during updates.');
|
||||||
|
// Clear the update cache and let it re-download
|
||||||
|
this.clearUpdateCache();
|
||||||
|
|
||||||
|
// Don't show error UI - just log and let it retry automatically on next check
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if this is a critical error that prevents auto-update
|
||||||
|
const isCriticalError = this.isCriticalUpdateError(err);
|
||||||
|
|
||||||
|
if (isCriticalError) {
|
||||||
|
this.autoUpdateAvailable = false;
|
||||||
|
console.warn('Auto-update failed. Manual download required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle missing metadata files (platform-specific builds)
|
||||||
|
if (err.code === 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND') {
|
||||||
|
const platform = process.platform === 'darwin' ? 'macOS' :
|
||||||
|
process.platform === 'win32' ? 'Windows' : 'Linux';
|
||||||
|
const missingFile = process.platform === 'darwin' ? 'latest-mac.yml' :
|
||||||
|
process.platform === 'win32' ? 'latest.yml' : 'latest-linux.yml';
|
||||||
|
console.warn(`${platform} update metadata file (${missingFile}) not found in release.`);
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('update-error', {
|
||||||
|
message: `Update metadata file for ${platform} not found in release. Please download manually.`,
|
||||||
|
code: err.code,
|
||||||
|
requiresManualDownload: true,
|
||||||
|
updateVersion: this.updateVersion,
|
||||||
|
isMissingMetadata: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux-specific: Handle installation permission errors
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const errorMessage = err.message?.toLowerCase() || '';
|
||||||
|
const errorStack = err.stack?.toLowerCase() || '';
|
||||||
|
const isInstallError = errorMessage.includes('pkexec') ||
|
||||||
|
errorMessage.includes('gksudo') ||
|
||||||
|
errorMessage.includes('kdesudo') ||
|
||||||
|
errorMessage.includes('setuid root') ||
|
||||||
|
errorMessage.includes('exited with code 127') ||
|
||||||
|
errorStack.includes('pacmanupdater') ||
|
||||||
|
errorStack.includes('doinstall') ||
|
||||||
|
errorMessage.includes('installation failed');
|
||||||
|
|
||||||
|
if (isInstallError) {
|
||||||
|
console.warn('Linux installation error: Package installation requires root privileges. Manual installation required.');
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('update-error', {
|
||||||
|
message: 'Auto-installation requires root privileges. Please download and install the update manually.',
|
||||||
|
code: err.code || 'ERR_LINUX_INSTALL_PERMISSION',
|
||||||
|
isLinuxInstallError: true,
|
||||||
|
requiresManualDownload: true,
|
||||||
|
updateVersion: this.updateVersion
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS-specific: Handle unsigned app errors gracefully
|
||||||
|
if (process.platform === 'darwin' && err.code === 2) {
|
||||||
|
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: 'Please download manually from GitHub.',
|
||||||
|
code: err.code,
|
||||||
|
isMacSigningError: true,
|
||||||
|
requiresManualDownload: true,
|
||||||
|
updateVersion: this.updateVersion
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('update-error', {
|
||||||
|
message: err.message,
|
||||||
|
code: err.code,
|
||||||
|
requiresManualDownload: isCriticalError,
|
||||||
|
updateVersion: this.updateVersion
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('download-progress', (progressObj) => {
|
||||||
|
const message = `Download speed: ${progressObj.bytesPerSecond} - Downloaded ${progressObj.percent}% (${progressObj.transferred}/${progressObj.total})`;
|
||||||
|
console.log(message);
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('update-download-progress', {
|
||||||
|
percent: progressObj.percent,
|
||||||
|
bytesPerSecond: progressObj.bytesPerSecond,
|
||||||
|
transferred: progressObj.transferred,
|
||||||
|
total: progressObj.total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('update-downloaded', (info) => {
|
||||||
|
console.log('Update downloaded:', info.version);
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('update-downloaded', {
|
||||||
|
version: info.version,
|
||||||
|
releaseName: info.releaseName,
|
||||||
|
releaseNotes: info.releaseNotes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkForUpdatesAndNotify() {
|
||||||
|
// Check for updates and notify if available
|
||||||
|
autoUpdater.checkForUpdatesAndNotify().catch(err => {
|
||||||
|
console.error('Failed to check for updates:', err);
|
||||||
|
|
||||||
|
// Network errors are not critical - just log and continue
|
||||||
|
const errorMessage = err.message?.toLowerCase() || '';
|
||||||
|
const isNetworkError = errorMessage.includes('err_name_not_resolved') ||
|
||||||
|
errorMessage.includes('network') ||
|
||||||
|
errorMessage.includes('connection') ||
|
||||||
|
errorMessage.includes('timeout') ||
|
||||||
|
errorMessage.includes('enotfound');
|
||||||
|
|
||||||
|
if (isNetworkError) {
|
||||||
|
console.warn('Network error checking for updates - will retry later. This is not critical.');
|
||||||
|
return; // Don't show error UI for network issues
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCritical = this.isCriticalUpdateError(err);
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed() && isCritical) {
|
||||||
|
this.mainWindow.webContents.send('update-error', {
|
||||||
|
message: err.message || 'Failed to check for updates',
|
||||||
|
code: err.code,
|
||||||
|
requiresManualDownload: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkForUpdates() {
|
||||||
|
// Manual check for updates (returns promise)
|
||||||
|
return autoUpdater.checkForUpdates().catch(err => {
|
||||||
|
console.error('Failed to check for updates:', err);
|
||||||
|
|
||||||
|
// Network errors are not critical - just return no update available
|
||||||
|
const errorMessage = err.message?.toLowerCase() || '';
|
||||||
|
const isNetworkError = errorMessage.includes('err_name_not_resolved') ||
|
||||||
|
errorMessage.includes('network') ||
|
||||||
|
errorMessage.includes('connection') ||
|
||||||
|
errorMessage.includes('timeout') ||
|
||||||
|
errorMessage.includes('enotfound');
|
||||||
|
|
||||||
|
if (isNetworkError) {
|
||||||
|
console.warn('Network error - update check unavailable');
|
||||||
|
return { updateInfo: null }; // Return empty result for network errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCritical = this.isCriticalUpdateError(err);
|
||||||
|
if (isCritical) {
|
||||||
|
this.autoUpdateAvailable = false;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
quitAndInstall() {
|
||||||
|
// Quit and install the update
|
||||||
|
autoUpdater.quitAndInstall(false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
getUpdateInfo() {
|
||||||
|
return {
|
||||||
|
currentVersion: app.getVersion(),
|
||||||
|
updateAvailable: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUpdateCache() {
|
||||||
|
try {
|
||||||
|
// Get the cache directory based on platform
|
||||||
|
const cacheDir = process.platform === 'darwin'
|
||||||
|
? path.join(os.homedir(), 'Library', 'Caches', `${app.getName()}-updater`)
|
||||||
|
: process.platform === 'win32'
|
||||||
|
? path.join(os.homedir(), 'AppData', 'Local', `${app.getName()}-updater`)
|
||||||
|
: path.join(os.homedir(), '.cache', `${app.getName()}-updater`);
|
||||||
|
|
||||||
|
if (fs.existsSync(cacheDir)) {
|
||||||
|
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||||
|
console.log('Update cache cleared successfully');
|
||||||
|
} else {
|
||||||
|
console.log('Update cache directory does not exist');
|
||||||
|
}
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.warn('Could not clear update cache:', cacheError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isCriticalUpdateError(err) {
|
||||||
|
// Check for errors that prevent auto-update
|
||||||
|
const errorMessage = err.message?.toLowerCase() || '';
|
||||||
|
const errorCode = err.code;
|
||||||
|
|
||||||
|
// Missing update metadata files (platform-specific)
|
||||||
|
if (errorCode === 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND' ||
|
||||||
|
errorMessage.includes('cannot find latest') ||
|
||||||
|
errorMessage.includes('latest-linux.yml') ||
|
||||||
|
errorMessage.includes('latest-mac.yml') ||
|
||||||
|
errorMessage.includes('latest.yml')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS code signing errors
|
||||||
|
if (process.platform === 'darwin' && (errorCode === 2 || errorMessage.includes('shipit'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download failures
|
||||||
|
if (errorMessage.includes('download') && errorMessage.includes('fail')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network errors that prevent download (but we handle these separately as non-critical)
|
||||||
|
// Installation errors
|
||||||
|
if (errorMessage.includes('install') && errorMessage.includes('fail')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission errors
|
||||||
|
if (errorMessage.includes('permission') || errorMessage.includes('access denied')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux installation errors (pkexec, sudo issues)
|
||||||
|
if (process.platform === 'linux' && (
|
||||||
|
errorMessage.includes('pkexec') ||
|
||||||
|
errorMessage.includes('setuid root') ||
|
||||||
|
errorMessage.includes('exited with code 127') ||
|
||||||
|
errorMessage.includes('gksudo') ||
|
||||||
|
errorMessage.includes('kdesudo'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File system errors (but not "not found" for metadata files - handled above)
|
||||||
|
if (errorMessage.includes('enoent') || errorMessage.includes('cannot find')) {
|
||||||
|
// Only if it's not about metadata files
|
||||||
|
if (!errorMessage.includes('latest') && !errorMessage.includes('.yml')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic critical error codes (but not checksum errors - those are handled separately)
|
||||||
|
if (errorCode && (errorCode >= 100 ||
|
||||||
|
errorCode === 'ERR_UPDATER_INVALID_RELEASE_FEED' ||
|
||||||
|
errorCode === 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND')) {
|
||||||
|
// Don't treat checksum errors as critical - they're handled separately
|
||||||
|
if (errorCode === 'ERR_CHECKSUM_MISMATCH') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AppUpdater;
|
||||||
@@ -2,6 +2,41 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
|
||||||
|
|
||||||
|
// Default auth domain - can be overridden by env var or config
|
||||||
|
const DEFAULT_AUTH_DOMAIN = 'auth.sanasol.ws';
|
||||||
|
|
||||||
|
// Get auth domain from env, config, or default
|
||||||
|
function getAuthDomain() {
|
||||||
|
// First check environment variable
|
||||||
|
if (process.env.HYTALE_AUTH_DOMAIN) {
|
||||||
|
return process.env.HYTALE_AUTH_DOMAIN;
|
||||||
|
}
|
||||||
|
// Then check config file
|
||||||
|
const config = loadConfig();
|
||||||
|
if (config.activeProfileId && config.profiles && config.profiles[config.activeProfileId]) {
|
||||||
|
// Allow profile to override auth domain if ever needed
|
||||||
|
// but for now stick to global or env
|
||||||
|
}
|
||||||
|
if (config.authDomain) {
|
||||||
|
return config.authDomain;
|
||||||
|
}
|
||||||
|
// Fall back to default
|
||||||
|
return DEFAULT_AUTH_DOMAIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get full auth server URL
|
||||||
|
// Domain already includes subdomain (auth.sanasol.ws), so use directly
|
||||||
|
function getAuthServerUrl() {
|
||||||
|
const domain = getAuthDomain();
|
||||||
|
return `https://${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save auth domain to config
|
||||||
|
function saveAuthDomain(domain) {
|
||||||
|
saveConfig({ authDomain: domain || DEFAULT_AUTH_DOMAIN });
|
||||||
|
}
|
||||||
|
|
||||||
function getAppDir() {
|
function getAppDir() {
|
||||||
const home = os.homedir();
|
const home = os.homedir();
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
@@ -81,6 +116,16 @@ function saveJavaPath(javaPath) {
|
|||||||
|
|
||||||
function loadJavaPath() {
|
function loadJavaPath() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// Prefer Active Profile's Java Path
|
||||||
|
if (config.activeProfileId && config.profiles && config.profiles[config.activeProfileId]) {
|
||||||
|
const profile = config.profiles[config.activeProfileId];
|
||||||
|
if (profile.javaPath && profile.javaPath.trim().length > 0) {
|
||||||
|
return profile.javaPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to global setting
|
||||||
return config.javaPath || '';
|
return config.javaPath || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,16 +139,63 @@ function loadInstallPath() {
|
|||||||
return config.installPath || '';
|
return config.installPath || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveDiscordRPC(enabled) {
|
||||||
|
saveConfig({ discordRPC: !!enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDiscordRPC() {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.discordRPC !== undefined ? config.discordRPC : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLanguage(language) {
|
||||||
|
saveConfig({ language: language || 'en' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLanguage() {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.language || 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCloseLauncherOnStart(enabled) {
|
||||||
|
saveConfig({ closeLauncherOnStart: !!enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCloseLauncherOnStart() {
|
||||||
|
const config = loadConfig();
|
||||||
|
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) {
|
function saveModsToConfig(mods) {
|
||||||
try {
|
try {
|
||||||
let config = loadConfig();
|
const config = loadConfig();
|
||||||
config.installedMods = mods;
|
|
||||||
|
// Config migration handles structure, but mod saves must go to the ACTIVE profile.
|
||||||
|
// Global installedMods is kept mainly for reference/migration.
|
||||||
|
// The profile is the source of truth for enabled mods.
|
||||||
|
|
||||||
|
|
||||||
|
if (config.activeProfileId && config.profiles && config.profiles[config.activeProfileId]) {
|
||||||
|
config.profiles[config.activeProfileId].mods = mods;
|
||||||
|
} else {
|
||||||
|
// Fallback for legacy or no-profile state
|
||||||
|
config.installedMods = mods;
|
||||||
|
}
|
||||||
|
|
||||||
const configDir = path.dirname(CONFIG_FILE);
|
const configDir = path.dirname(CONFIG_FILE);
|
||||||
if (!fs.existsSync(configDir)) {
|
if (!fs.existsSync(configDir)) {
|
||||||
fs.mkdirSync(configDir, { recursive: true });
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||||
console.log('Mods saved to config.json');
|
console.log('Mods saved to config.json');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -114,6 +206,12 @@ function saveModsToConfig(mods) {
|
|||||||
function loadModsFromConfig() {
|
function loadModsFromConfig() {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// Prefer Active Profile
|
||||||
|
if (config.activeProfileId && config.profiles && config.profiles[config.activeProfileId]) {
|
||||||
|
return config.profiles[config.activeProfileId].mods || [];
|
||||||
|
}
|
||||||
|
|
||||||
return config.installedMods || [];
|
return config.installedMods || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading mods from config:', error);
|
console.error('Error loading mods from config:', error);
|
||||||
@@ -123,19 +221,19 @@ function loadModsFromConfig() {
|
|||||||
|
|
||||||
function isFirstLaunch() {
|
function isFirstLaunch() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
if ('hasLaunchedBefore' in config) {
|
if ('hasLaunchedBefore' in config) {
|
||||||
return !config.hasLaunchedBefore;
|
return !config.hasLaunchedBefore;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUserData = config.installPath || config.username || config.javaPath ||
|
const hasUserData = config.installPath || config.username || config.javaPath ||
|
||||||
config.chatUsername || config.userUuids ||
|
config.chatUsername || config.userUuids ||
|
||||||
Object.keys(config).length > 0;
|
Object.keys(config).length > 0;
|
||||||
|
|
||||||
if (!hasUserData) {
|
if (!hasUserData) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +241,103 @@ function markAsLaunched() {
|
|||||||
saveConfig({ hasLaunchedBefore: true, firstLaunchDate: new Date().toISOString() });
|
saveConfig({ hasLaunchedBefore: true, firstLaunchDate: new Date().toISOString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UUID Management Functions
|
||||||
|
function getCurrentUuid() {
|
||||||
|
const username = loadUsername();
|
||||||
|
return getUuidForUser(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllUuidMappings() {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.userUuids || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUuidForUser(username, uuid) {
|
||||||
|
const { v4: uuidv4, validate: validateUuid } = require('uuid');
|
||||||
|
|
||||||
|
// Validate UUID format
|
||||||
|
if (!validateUuid(uuid)) {
|
||||||
|
throw new Error('Invalid UUID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const userUuids = config.userUuids || {};
|
||||||
|
userUuids[username] = uuid;
|
||||||
|
saveConfig({ userUuids });
|
||||||
|
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNewUuid() {
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
return uuidv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteUuidForUser(username) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const userUuids = config.userUuids || {};
|
||||||
|
|
||||||
|
if (userUuids[username]) {
|
||||||
|
delete userUuids[username];
|
||||||
|
saveConfig({ userUuids });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCurrentUserUuid() {
|
||||||
|
const username = loadUsername();
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const newUuid = uuidv4();
|
||||||
|
|
||||||
|
return setUuidForUser(username, newUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveChatColor(color) {
|
||||||
|
const config = loadConfig();
|
||||||
|
config.chatColor = color;
|
||||||
|
saveConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChatColor() {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.chatColor || '#3498db';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGpuPreference(gpuPreference) {
|
||||||
|
saveConfig({ gpuPreference: gpuPreference || 'auto' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadGpuPreference() {
|
||||||
|
const config = loadConfig();
|
||||||
|
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 = {
|
module.exports = {
|
||||||
loadConfig,
|
loadConfig,
|
||||||
saveConfig,
|
saveConfig,
|
||||||
@@ -150,14 +345,47 @@ module.exports = {
|
|||||||
loadUsername,
|
loadUsername,
|
||||||
saveChatUsername,
|
saveChatUsername,
|
||||||
loadChatUsername,
|
loadChatUsername,
|
||||||
|
saveChatColor,
|
||||||
|
loadChatColor,
|
||||||
getUuidForUser,
|
getUuidForUser,
|
||||||
saveJavaPath,
|
saveJavaPath,
|
||||||
loadJavaPath,
|
loadJavaPath,
|
||||||
saveInstallPath,
|
saveInstallPath,
|
||||||
loadInstallPath,
|
loadInstallPath,
|
||||||
|
saveDiscordRPC,
|
||||||
|
loadDiscordRPC,
|
||||||
|
saveLanguage,
|
||||||
|
loadLanguage,
|
||||||
saveModsToConfig,
|
saveModsToConfig,
|
||||||
loadModsFromConfig,
|
loadModsFromConfig,
|
||||||
isFirstLaunch,
|
isFirstLaunch,
|
||||||
markAsLaunched,
|
markAsLaunched,
|
||||||
CONFIG_FILE
|
CONFIG_FILE,
|
||||||
|
// Auth server exports
|
||||||
|
getAuthServerUrl,
|
||||||
|
getAuthDomain,
|
||||||
|
saveAuthDomain,
|
||||||
|
// UUID Management exports
|
||||||
|
getCurrentUuid,
|
||||||
|
getAllUuidMappings,
|
||||||
|
setUuidForUser,
|
||||||
|
generateNewUuid,
|
||||||
|
deleteUuidForUser,
|
||||||
|
resetCurrentUserUuid,
|
||||||
|
// GPU Preference exports
|
||||||
|
saveGpuPreference,
|
||||||
|
loadGpuPreference,
|
||||||
|
// Close Launcher export
|
||||||
|
saveCloseLauncherOnStart,
|
||||||
|
loadCloseLauncherOnStart,
|
||||||
|
|
||||||
|
// Hardware Acceleration functions
|
||||||
|
saveLauncherHardwareAcceleration,
|
||||||
|
loadLauncherHardwareAcceleration,
|
||||||
|
|
||||||
|
// Version Management exports
|
||||||
|
saveVersionClient,
|
||||||
|
loadVersionClient,
|
||||||
|
saveVersionBranch,
|
||||||
|
loadVersionBranch
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const { loadVersionBranch } = require('./config');
|
||||||
|
|
||||||
function getAppDir() {
|
function getAppDir() {
|
||||||
const home = os.homedir();
|
const home = os.homedir();
|
||||||
@@ -13,6 +14,21 @@ function getAppDir() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get centralized UserData saves directory (NEW in 2.1.2)
|
||||||
|
* UserData is now stored separately from game installation
|
||||||
|
*/
|
||||||
|
function getHytaleSavesDir() {
|
||||||
|
const home = os.homedir();
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return path.join(home, 'AppData', 'Local', 'HytaleSaves');
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
return path.join(home, 'Library', 'Application Support', 'HytaleSaves');
|
||||||
|
} else {
|
||||||
|
return path.join(home, '.hytalesaves');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_APP_DIR = getAppDir();
|
const DEFAULT_APP_DIR = getAppDir();
|
||||||
|
|
||||||
function getResolvedAppDir(customPath) {
|
function getResolvedAppDir(customPath) {
|
||||||
@@ -48,8 +64,20 @@ function expandHome(inputPath) {
|
|||||||
const APP_DIR = DEFAULT_APP_DIR;
|
const APP_DIR = DEFAULT_APP_DIR;
|
||||||
const CACHE_DIR = path.join(APP_DIR, 'cache');
|
const CACHE_DIR = path.join(APP_DIR, 'cache');
|
||||||
const TOOLS_DIR = path.join(APP_DIR, 'butler');
|
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');
|
const PLAYER_ID_FILE = path.join(APP_DIR, 'player_id.json');
|
||||||
|
|
||||||
function getClientCandidates(gameLatest) {
|
function getClientCandidates(gameLatest) {
|
||||||
@@ -77,32 +105,32 @@ function findClientPath(gameLatest) {
|
|||||||
|
|
||||||
function findUserDataPath(gameLatest) {
|
function findUserDataPath(gameLatest) {
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
|
|
||||||
candidates.push(path.join(gameLatest, 'Client', 'UserData'));
|
candidates.push(path.join(gameLatest, 'Client', 'UserData'));
|
||||||
|
|
||||||
candidates.push(path.join(gameLatest, 'Client', 'Hytale.app', 'Contents', 'UserData'));
|
candidates.push(path.join(gameLatest, 'Client', 'Hytale.app', 'Contents', 'UserData'));
|
||||||
candidates.push(path.join(gameLatest, 'Hytale.app', 'Contents', 'UserData'));
|
candidates.push(path.join(gameLatest, 'Hytale.app', 'Contents', 'UserData'));
|
||||||
candidates.push(path.join(gameLatest, 'UserData'));
|
candidates.push(path.join(gameLatest, 'UserData'));
|
||||||
|
|
||||||
candidates.push(path.join(gameLatest, 'Client', 'UserData'));
|
candidates.push(path.join(gameLatest, 'Client', 'UserData'));
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (fs.existsSync(candidate)) {
|
if (fs.existsSync(candidate)) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let defaultPath;
|
let defaultPath;
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
defaultPath = path.join(gameLatest, 'Client', 'UserData');
|
defaultPath = path.join(gameLatest, 'Client', 'UserData');
|
||||||
} else {
|
} else {
|
||||||
defaultPath = path.join(gameLatest, 'Client', 'UserData');
|
defaultPath = path.join(gameLatest, 'Client', 'UserData');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(defaultPath)) {
|
if (!fs.existsSync(defaultPath)) {
|
||||||
fs.mkdirSync(defaultPath, { recursive: true });
|
fs.mkdirSync(defaultPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaultPath;
|
return defaultPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,15 +138,15 @@ function findUserDataRecursive(gameLatest) {
|
|||||||
function searchDirectory(dir) {
|
function searchDirectory(dir) {
|
||||||
try {
|
try {
|
||||||
const items = fs.readdirSync(dir, { withFileTypes: true });
|
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.isDirectory()) {
|
if (item.isDirectory()) {
|
||||||
const fullPath = path.join(dir, item.name);
|
const fullPath = path.join(dir, item.name);
|
||||||
|
|
||||||
if (item.name === 'UserData') {
|
if (item.name === 'UserData') {
|
||||||
return fullPath;
|
return fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const found = searchDirectory(fullPath);
|
const found = searchDirectory(fullPath);
|
||||||
if (found) {
|
if (found) {
|
||||||
return found;
|
return found;
|
||||||
@@ -127,14 +155,14 @@ function findUserDataRecursive(gameLatest) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(gameLatest)) {
|
if (!fs.existsSync(gameLatest)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const found = searchDirectory(gameLatest);
|
const found = searchDirectory(gameLatest);
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
@@ -152,25 +180,49 @@ async function getModsPath(customInstallPath = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!installPath) {
|
if (!installPath) {
|
||||||
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
// Use the standard app directory logic which handles platforms correctly
|
||||||
installPath = path.join(localAppData, 'HytaleF2P');
|
installPath = getAppDir();
|
||||||
} else {
|
|
||||||
installPath = path.join(installPath, 'HytaleF2P');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 userDataPath = findUserDataPath(gameLatest);
|
||||||
|
|
||||||
const modsPath = path.join(userDataPath, 'Mods');
|
const modsPath = path.join(userDataPath, 'Mods');
|
||||||
const disabledModsPath = path.join(userDataPath, 'DisabledMods');
|
const disabledModsPath = path.join(userDataPath, 'DisabledMods');
|
||||||
|
const profilesPath = path.join(userDataPath, 'Profiles');
|
||||||
|
|
||||||
if (!fs.existsSync(modsPath)) {
|
if (!fs.existsSync(modsPath)) {
|
||||||
fs.mkdirSync(modsPath, { recursive: true });
|
// Check for broken symlink to avoid EEXIST/EPERM on mkdir
|
||||||
|
let isBrokenLink = false;
|
||||||
|
let pathExists = false;
|
||||||
|
try {
|
||||||
|
const stats = fs.lstatSync(modsPath);
|
||||||
|
pathExists = true;
|
||||||
|
if (stats.isSymbolicLink()) {
|
||||||
|
// Check if target exists
|
||||||
|
try {
|
||||||
|
fs.statSync(modsPath);
|
||||||
|
} catch {
|
||||||
|
isBrokenLink = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* path doesn't exist at all */ }
|
||||||
|
|
||||||
|
if (isBrokenLink) {
|
||||||
|
fs.unlinkSync(modsPath); // Remove broken symlink
|
||||||
|
}
|
||||||
|
if (!pathExists || isBrokenLink) {
|
||||||
|
fs.mkdirSync(modsPath, { recursive: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!fs.existsSync(disabledModsPath)) {
|
if (!fs.existsSync(disabledModsPath)) {
|
||||||
fs.mkdirSync(disabledModsPath, { recursive: true });
|
fs.mkdirSync(disabledModsPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
if (!fs.existsSync(profilesPath)) {
|
||||||
|
fs.mkdirSync(profilesPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
return modsPath;
|
return modsPath;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -179,8 +231,26 @@ async function getModsPath(customInstallPath = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProfilesDir(customInstallPath = null) {
|
||||||
|
try {
|
||||||
|
// NEW 2.1.2: Use centralized UserData location
|
||||||
|
const userDataPath = getHytaleSavesDir();
|
||||||
|
const profilesDir = path.join(userDataPath, 'Profiles');
|
||||||
|
|
||||||
|
if (!fs.existsSync(profilesDir)) {
|
||||||
|
fs.mkdirSync(profilesDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return profilesDir;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting profiles dir:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAppDir,
|
getAppDir,
|
||||||
|
getHytaleSavesDir,
|
||||||
getResolvedAppDir,
|
getResolvedAppDir,
|
||||||
expandHome,
|
expandHome,
|
||||||
APP_DIR,
|
APP_DIR,
|
||||||
@@ -188,10 +258,13 @@ module.exports = {
|
|||||||
TOOLS_DIR,
|
TOOLS_DIR,
|
||||||
GAME_DIR,
|
GAME_DIR,
|
||||||
JRE_DIR,
|
JRE_DIR,
|
||||||
|
getGameDir,
|
||||||
|
getJreDir,
|
||||||
PLAYER_ID_FILE,
|
PLAYER_ID_FILE,
|
||||||
getClientCandidates,
|
getClientCandidates,
|
||||||
findClientPath,
|
findClientPath,
|
||||||
findUserDataPath,
|
findUserDataPath,
|
||||||
findUserDataRecursive,
|
findUserDataRecursive,
|
||||||
getModsPath
|
getModsPath,
|
||||||
|
getProfilesDir
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,16 +7,44 @@ const {
|
|||||||
loadUsername,
|
loadUsername,
|
||||||
saveChatUsername,
|
saveChatUsername,
|
||||||
loadChatUsername,
|
loadChatUsername,
|
||||||
|
saveChatColor,
|
||||||
|
loadChatColor,
|
||||||
saveJavaPath,
|
saveJavaPath,
|
||||||
loadJavaPath,
|
loadJavaPath,
|
||||||
saveInstallPath,
|
saveInstallPath,
|
||||||
loadInstallPath,
|
loadInstallPath,
|
||||||
|
saveDiscordRPC,
|
||||||
|
loadDiscordRPC,
|
||||||
|
saveLanguage,
|
||||||
|
loadLanguage,
|
||||||
|
saveCloseLauncherOnStart,
|
||||||
|
loadCloseLauncherOnStart,
|
||||||
|
|
||||||
|
// Hardware Acceleration
|
||||||
|
saveLauncherHardwareAcceleration,
|
||||||
|
loadLauncherHardwareAcceleration,
|
||||||
|
|
||||||
|
|
||||||
saveModsToConfig,
|
saveModsToConfig,
|
||||||
loadModsFromConfig,
|
loadModsFromConfig,
|
||||||
getUuidForUser,
|
getUuidForUser,
|
||||||
isFirstLaunch,
|
isFirstLaunch,
|
||||||
markAsLaunched,
|
markAsLaunched,
|
||||||
CONFIG_FILE
|
// UUID Management
|
||||||
|
getCurrentUuid,
|
||||||
|
getAllUuidMappings,
|
||||||
|
setUuidForUser,
|
||||||
|
generateNewUuid,
|
||||||
|
deleteUuidForUser,
|
||||||
|
resetCurrentUserUuid,
|
||||||
|
// GPU Preference
|
||||||
|
saveGpuPreference,
|
||||||
|
loadGpuPreference,
|
||||||
|
// Version Management
|
||||||
|
saveVersionClient,
|
||||||
|
loadVersionClient,
|
||||||
|
saveVersionBranch,
|
||||||
|
loadVersionBranch
|
||||||
} = require('./core/config');
|
} = require('./core/config');
|
||||||
|
|
||||||
const { getResolvedAppDir, getModsPath } = require('./core/paths');
|
const { getResolvedAppDir, getModsPath } = require('./core/paths');
|
||||||
@@ -27,7 +55,8 @@ const {
|
|||||||
installGame,
|
installGame,
|
||||||
uninstallGame,
|
uninstallGame,
|
||||||
updateGameFiles,
|
updateGameFiles,
|
||||||
checkExistingGameInstallation
|
checkExistingGameInstallation,
|
||||||
|
repairGame
|
||||||
} = require('./managers/gameManager');
|
} = require('./managers/gameManager');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -37,8 +66,6 @@ const {
|
|||||||
|
|
||||||
const { getJavaDetection } = require('./managers/javaManager');
|
const { getJavaDetection } = require('./managers/javaManager');
|
||||||
|
|
||||||
const { checkAndInstallMultiClient } = require('./managers/multiClientManager');
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
downloadAndReplaceHomePageUI,
|
downloadAndReplaceHomePageUI,
|
||||||
findHomePageUIPath,
|
findHomePageUIPath,
|
||||||
@@ -55,7 +82,6 @@ const {
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
const {
|
const {
|
||||||
getInstalledClientVersion,
|
|
||||||
getLatestClientVersion
|
getLatestClientVersion
|
||||||
} = require('./services/versionManager');
|
} = require('./services/versionManager');
|
||||||
|
|
||||||
@@ -68,47 +94,82 @@ const {
|
|||||||
handleFirstLaunchCheck
|
handleFirstLaunchCheck
|
||||||
} = require('./services/firstLaunch');
|
} = require('./services/firstLaunch');
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
const { detectGpu } = require('./utils/platformUtils');
|
||||||
|
|
||||||
// Re-export all functions to maintain backward compatibility
|
// Re-export all functions to maintain backward compatibility
|
||||||
module.exports = {
|
module.exports = {
|
||||||
// Game launch functions
|
// Game launch functions
|
||||||
launchGame,
|
launchGame,
|
||||||
launchGameWithVersionCheck,
|
launchGameWithVersionCheck,
|
||||||
|
|
||||||
// Game installation functions
|
// Game installation functions
|
||||||
installGame,
|
installGame,
|
||||||
isGameInstalled,
|
isGameInstalled,
|
||||||
uninstallGame,
|
uninstallGame,
|
||||||
updateGameFiles,
|
updateGameFiles,
|
||||||
|
repairGame,
|
||||||
|
|
||||||
// User configuration functions
|
// User configuration functions
|
||||||
saveUsername,
|
saveUsername,
|
||||||
loadUsername,
|
loadUsername,
|
||||||
saveChatUsername,
|
saveChatUsername,
|
||||||
loadChatUsername,
|
loadChatUsername,
|
||||||
|
saveChatColor,
|
||||||
|
loadChatColor,
|
||||||
getUuidForUser,
|
getUuidForUser,
|
||||||
|
|
||||||
// Java configuration functions
|
// Java configuration functions
|
||||||
saveJavaPath,
|
saveJavaPath,
|
||||||
loadJavaPath,
|
loadJavaPath,
|
||||||
getJavaDetection,
|
getJavaDetection,
|
||||||
|
|
||||||
// Installation path functions
|
// Installation path functions
|
||||||
saveInstallPath,
|
saveInstallPath,
|
||||||
loadInstallPath,
|
loadInstallPath,
|
||||||
|
|
||||||
|
// Discord RPC functions
|
||||||
|
saveDiscordRPC,
|
||||||
|
loadDiscordRPC,
|
||||||
|
|
||||||
|
// Language functions
|
||||||
|
saveLanguage,
|
||||||
|
loadLanguage,
|
||||||
|
|
||||||
|
// Close Launcher functions
|
||||||
|
saveCloseLauncherOnStart,
|
||||||
|
loadCloseLauncherOnStart,
|
||||||
|
|
||||||
|
// Hardware Acceleration functions
|
||||||
|
saveLauncherHardwareAcceleration,
|
||||||
|
loadLauncherHardwareAcceleration,
|
||||||
|
|
||||||
|
// GPU Preference functions
|
||||||
|
saveGpuPreference,
|
||||||
|
loadGpuPreference,
|
||||||
|
detectGpu,
|
||||||
|
|
||||||
// Version functions
|
// Version functions
|
||||||
getInstalledClientVersion,
|
|
||||||
getLatestClientVersion,
|
getLatestClientVersion,
|
||||||
|
saveVersionClient,
|
||||||
|
loadVersionClient,
|
||||||
|
saveVersionBranch,
|
||||||
|
loadVersionBranch,
|
||||||
|
|
||||||
// News functions
|
// News functions
|
||||||
getHytaleNews,
|
getHytaleNews,
|
||||||
|
|
||||||
// Player ID functions
|
// Player ID functions
|
||||||
getOrCreatePlayerId,
|
getOrCreatePlayerId,
|
||||||
|
|
||||||
// Multi-client functions
|
// UUID Management functions
|
||||||
checkAndInstallMultiClient,
|
getCurrentUuid,
|
||||||
|
getAllUuidMappings,
|
||||||
|
setUuidForUser,
|
||||||
|
generateNewUuid,
|
||||||
|
deleteUuidForUser,
|
||||||
|
resetCurrentUserUuid,
|
||||||
|
|
||||||
// Mod management functions
|
// Mod management functions
|
||||||
getModsPath,
|
getModsPath,
|
||||||
loadInstalledMods,
|
loadInstalledMods,
|
||||||
@@ -117,20 +178,20 @@ module.exports = {
|
|||||||
toggleMod,
|
toggleMod,
|
||||||
saveModsToConfig,
|
saveModsToConfig,
|
||||||
loadModsFromConfig,
|
loadModsFromConfig,
|
||||||
|
|
||||||
// UI file management functions
|
// UI file management functions
|
||||||
downloadAndReplaceHomePageUI,
|
downloadAndReplaceHomePageUI,
|
||||||
findHomePageUIPath,
|
findHomePageUIPath,
|
||||||
downloadAndReplaceLogo,
|
downloadAndReplaceLogo,
|
||||||
findLogoPath,
|
findLogoPath,
|
||||||
|
|
||||||
// First launch functions
|
// First launch functions
|
||||||
isFirstLaunch,
|
isFirstLaunch,
|
||||||
markAsLaunched,
|
markAsLaunched,
|
||||||
checkExistingGameInstallation,
|
checkExistingGameInstallation,
|
||||||
proposeGameUpdate,
|
proposeGameUpdate,
|
||||||
handleFirstLaunchCheck,
|
handleFirstLaunchCheck,
|
||||||
|
|
||||||
// Path functions
|
// Path functions
|
||||||
getResolvedAppDir
|
getResolvedAppDir
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class Logger {
|
|||||||
|
|
||||||
fs.appendFileSync(this.logFile, message, 'utf8');
|
fs.appendFileSync(this.logFile, message, 'utf8');
|
||||||
} catch (error) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,166 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { exec } = require('child_process');
|
const { exec } = require('child_process');
|
||||||
const { promisify } = require('util');
|
const { promisify } = require('util');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { getResolvedAppDir, findClientPath } = require('../core/paths');
|
const { getResolvedAppDir, findClientPath } = require('../core/paths');
|
||||||
const { setupWaylandEnvironment } = require('../utils/platformUtils');
|
const { setupWaylandEnvironment, setupGpuEnvironment } = require('../utils/platformUtils');
|
||||||
const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser } = 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 { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager');
|
||||||
const { getInstalledClientVersion, getLatestClientVersion } = require('../services/versionManager');
|
const { getLatestClientVersion } = require('../services/versionManager');
|
||||||
const { updateGameFiles } = require('./gameManager');
|
const { updateGameFiles } = require('./gameManager');
|
||||||
|
const { syncModsForCurrentProfile } = require('./modManager');
|
||||||
|
const { getUserDataPath } = require('../utils/userDataMigration');
|
||||||
|
|
||||||
|
// Client patcher for custom auth server (sanasol.ws)
|
||||||
|
let clientPatcher = null;
|
||||||
|
try {
|
||||||
|
clientPatcher = require('../utils/clientPatcher');
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[Launcher] Client patcher not available:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
async function launchGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride) {
|
/**
|
||||||
|
* Try to auto-install jemalloc on Linux using pkexec (graphical sudo)
|
||||||
|
* Returns true if installation was successful
|
||||||
|
*/
|
||||||
|
async function tryInstallJemalloc() {
|
||||||
|
console.log('Linux: Attempting to auto-install jemalloc...');
|
||||||
|
|
||||||
|
// Detect package manager and get install command
|
||||||
|
let installCmd = null;
|
||||||
|
try {
|
||||||
|
await execAsync('which pacman');
|
||||||
|
installCmd = 'pacman -S --noconfirm jemalloc';
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await execAsync('which apt');
|
||||||
|
installCmd = 'apt install -y libjemalloc2';
|
||||||
|
} catch (e2) {
|
||||||
|
try {
|
||||||
|
await execAsync('which dnf');
|
||||||
|
installCmd = 'dnf install -y jemalloc';
|
||||||
|
} catch (e3) {
|
||||||
|
console.log('Linux: Could not detect package manager for auto-install');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try pkexec first (graphical sudo), fall back to sudo
|
||||||
|
const sudoCommands = ['pkexec', 'sudo'];
|
||||||
|
for (const sudoCmd of sudoCommands) {
|
||||||
|
try {
|
||||||
|
await execAsync(`which ${sudoCmd}`);
|
||||||
|
console.log(`Linux: Installing jemalloc with: ${sudoCmd} ${installCmd}`);
|
||||||
|
await execAsync(`${sudoCmd} ${installCmd}`, { timeout: 120000 });
|
||||||
|
console.log('Linux: jemalloc installed successfully');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.killed) {
|
||||||
|
console.log('Linux: Install timed out');
|
||||||
|
} else if (e.code === 126 || e.code === 127) {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
console.log(`Linux: Install failed with ${sudoCmd}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Linux: Auto-install failed, manual installation required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch tokens from the auth server (properly signed with server's Ed25519 key)
|
||||||
|
async function fetchAuthTokens(uuid, name) {
|
||||||
|
const authServerUrl = getAuthServerUrl();
|
||||||
|
try {
|
||||||
|
console.log(`Fetching auth tokens from ${authServerUrl}/game-session/child`);
|
||||||
|
|
||||||
|
const response = await fetch(`${authServerUrl}/game-session/child`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
uuid: uuid,
|
||||||
|
name: name,
|
||||||
|
scopes: ['hytale:server', 'hytale:client']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Auth server returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Auth tokens received from server');
|
||||||
|
|
||||||
|
return {
|
||||||
|
identityToken: data.IdentityToken || data.identityToken,
|
||||||
|
sessionToken: data.SessionToken || data.sessionToken
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch auth tokens:', error.message);
|
||||||
|
// Fallback to local generation if server unavailable
|
||||||
|
return generateLocalTokens(uuid, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Generate tokens locally (won't pass signature validation but allows offline testing)
|
||||||
|
function generateLocalTokens(uuid, name) {
|
||||||
|
console.log('Using locally generated tokens (fallback mode)');
|
||||||
|
const authServerUrl = getAuthServerUrl();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const exp = now + 36000;
|
||||||
|
|
||||||
|
const header = Buffer.from(JSON.stringify({
|
||||||
|
alg: 'EdDSA',
|
||||||
|
kid: '2025-10-01',
|
||||||
|
typ: 'JWT'
|
||||||
|
})).toString('base64url');
|
||||||
|
|
||||||
|
const identityPayload = Buffer.from(JSON.stringify({
|
||||||
|
sub: uuid,
|
||||||
|
name: name,
|
||||||
|
username: name,
|
||||||
|
entitlements: ['game.base'],
|
||||||
|
scope: 'hytale:server hytale:client',
|
||||||
|
iat: now,
|
||||||
|
exp: exp,
|
||||||
|
iss: authServerUrl,
|
||||||
|
jti: uuidv4()
|
||||||
|
})).toString('base64url');
|
||||||
|
|
||||||
|
const sessionPayload = Buffer.from(JSON.stringify({
|
||||||
|
sub: uuid,
|
||||||
|
scope: 'hytale:server',
|
||||||
|
iat: now,
|
||||||
|
exp: exp,
|
||||||
|
iss: authServerUrl,
|
||||||
|
jti: uuidv4()
|
||||||
|
})).toString('base64url');
|
||||||
|
|
||||||
|
const signature = crypto.randomBytes(64).toString('base64url');
|
||||||
|
|
||||||
|
return {
|
||||||
|
identityToken: `${header}.${identityPayload}.${signature}`,
|
||||||
|
sessionToken: `${header}.${sessionPayload}.${signature}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
|
||||||
|
const branch = branchOverride || loadVersionBranch();
|
||||||
const customAppDir = getResolvedAppDir(installPathOverride);
|
const customAppDir = getResolvedAppDir(installPathOverride);
|
||||||
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
|
const customGameDir = path.join(customAppDir, branch, 'package', 'game', 'latest');
|
||||||
const customJreDir = path.join(customAppDir, 'release', 'package', 'jre', 'latest');
|
const customJreDir = path.join(customAppDir, branch, 'package', 'jre', 'latest');
|
||||||
const userDataDir = path.join(customGameDir, 'Client', 'UserData');
|
|
||||||
|
// NEW 2.1.2: Use centralized UserData location
|
||||||
|
const userDataDir = getUserDataPath();
|
||||||
|
|
||||||
const gameLatest = customGameDir;
|
const gameLatest = customGameDir;
|
||||||
let clientPath = findClientPath(gameLatest);
|
let clientPath = findClientPath(gameLatest);
|
||||||
@@ -42,7 +186,7 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
javaBin = getJavaExec(customJreDir);
|
javaBin = getJavaExec(customJreDir);
|
||||||
|
|
||||||
if (!getBundledJavaPath(customJreDir)) {
|
if (!getBundledJavaPath(customJreDir)) {
|
||||||
const fallback = await detectSystemJava();
|
const fallback = await detectSystemJava();
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
@@ -53,23 +197,65 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uuid = getUuidForUser(playerName);
|
||||||
|
|
||||||
|
// Fetch tokens from auth server
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Fetching authentication tokens...', null, null, null, null);
|
||||||
|
}
|
||||||
|
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(`Force patching game binaries for ${authDomain}...`);
|
||||||
|
|
||||||
|
const patchResult = await clientPatcher.ensureClientPatched(gameLatest, (msg, percent) => {
|
||||||
|
// console.log(`[Patcher] ${msg}`);
|
||||||
|
if (progressCallback && msg) {
|
||||||
|
progressCallback(msg, percent, null, null, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (patchResult.success) {
|
||||||
|
console.log(`Game patched successfully (${patchResult.patchCount} total occurrences)`);
|
||||||
|
if (patchResult.client) {
|
||||||
|
console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`);
|
||||||
|
}
|
||||||
|
if (patchResult.server) {
|
||||||
|
console.log(` Server: ${patchResult.server.patchCount || 0} occurrences`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Game patching failed:', patchResult.error);
|
||||||
|
}
|
||||||
|
} catch (patchError) {
|
||||||
|
console.warn('Game patching failed (game may not connect to custom server):', patchError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS: Sign binaries AFTER patching so the patched binaries have valid signatures
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
try {
|
try {
|
||||||
const appBundle = path.join(gameLatest, 'Client', 'Hytale.app');
|
const appBundle = path.join(gameLatest, 'Client', 'Hytale.app');
|
||||||
const serverDir = path.join(gameLatest, 'Server');
|
const serverDir = path.join(gameLatest, 'Server');
|
||||||
|
|
||||||
const signPath = async (targetPath, deep = false) => {
|
const signPath = async (targetPath, deep = false) => {
|
||||||
await execAsync(`xattr -cr "${targetPath}"`).catch(() => {});
|
await execAsync(`xattr -cr "${targetPath}"`).catch(() => { });
|
||||||
const deepFlag = deep ? '--deep ' : '';
|
const deepFlag = deep ? '--deep ' : '';
|
||||||
await execAsync(`codesign --force ${deepFlag}--sign - "${targetPath}"`).catch(() => {});
|
await execAsync(`codesign --force ${deepFlag}--sign - "${targetPath}"`).catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fs.existsSync(appBundle)) {
|
if (fs.existsSync(appBundle)) {
|
||||||
await signPath(appBundle, true);
|
await signPath(appBundle, true);
|
||||||
console.log('Signed macOS app bundle');
|
console.log('Signed macOS app bundle (after patching)');
|
||||||
} else {
|
} else {
|
||||||
await signPath(path.dirname(clientPath), true);
|
await signPath(path.dirname(clientPath), true);
|
||||||
console.log('Signed macOS client binary');
|
console.log('Signed macOS client binary (after patching)');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (javaBin && fs.existsSync(javaBin)) {
|
if (javaBin && fs.existsSync(javaBin)) {
|
||||||
@@ -83,9 +269,9 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fs.existsSync(serverDir)) {
|
if (fs.existsSync(serverDir)) {
|
||||||
await execAsync(`xattr -cr "${serverDir}"`).catch(() => {});
|
await execAsync(`xattr -cr "${serverDir}"`).catch(() => { });
|
||||||
await execAsync(`find "${serverDir}" -type f -perm +111 -exec codesign --force --sign - {} \\;`).catch(() => {});
|
await execAsync(`find "${serverDir}" -type f -perm +111 -exec codesign --force --sign - {} \\;`).catch(() => { });
|
||||||
console.log('Signed server binaries');
|
console.log('Signed server binaries (after patching)');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (javaBin && fs.existsSync(javaBin)) {
|
if (javaBin && fs.existsSync(javaBin)) {
|
||||||
@@ -113,26 +299,100 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uuid = getUuidForUser(playerName);
|
|
||||||
const args = [
|
const args = [
|
||||||
'--app-dir', gameLatest,
|
'--app-dir', gameLatest,
|
||||||
'--java-exec', javaBin,
|
'--java-exec', javaBin,
|
||||||
'--auth-mode', 'offline',
|
'--auth-mode', 'authenticated',
|
||||||
'--uuid', uuid,
|
'--uuid', uuid,
|
||||||
'--name', playerName,
|
'--name', playerName,
|
||||||
|
'--identity-token', identityToken,
|
||||||
|
'--session-token', sessionToken,
|
||||||
'--user-dir', userDataDir
|
'--user-dir', userDataDir
|
||||||
];
|
];
|
||||||
|
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback('Starting game...', null, null, null, null);
|
progressCallback('Starting game...', null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure mods are synced for the active profile before launching
|
||||||
|
try {
|
||||||
|
console.log('Syncing mods for active profile before launch...');
|
||||||
|
if (progressCallback) progressCallback('Syncing mods...', null, null, null, null);
|
||||||
|
await syncModsForCurrentProfile();
|
||||||
|
} catch (syncError) {
|
||||||
|
console.error('Failed to sync mods before launch:', syncError);
|
||||||
|
// Continue anyway? Or fail?
|
||||||
|
// Warn user but continue might be safer to avoid blocking play if sync is just glitchy
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Starting game...');
|
console.log('Starting game...');
|
||||||
console.log(`Command: "${clientPath}" ${args.join(' ')}`);
|
console.log(`Command: "${clientPath}" ${args.join(' ')}`);
|
||||||
|
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
|
|
||||||
const waylandEnv = setupWaylandEnvironment();
|
const waylandEnv = setupWaylandEnvironment();
|
||||||
Object.assign(env, waylandEnv);
|
Object.assign(env, waylandEnv);
|
||||||
|
|
||||||
|
const gpuEnv = setupGpuEnvironment(gpuPreference);
|
||||||
|
Object.assign(env, gpuEnv);
|
||||||
|
|
||||||
|
// Linux: Use jemalloc to fix "free(): invalid pointer" crash on glibc 2.41+ (Steam Deck, Ubuntu LTS)
|
||||||
|
// Root cause: glibc 2.41 has stricter heap validation that catches a pre-existing race condition
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
if (process.env.HYTALE_NO_JEMALLOC !== '1') {
|
||||||
|
const jemallocPaths = [
|
||||||
|
'/usr/lib/libjemalloc.so.2', // Arch Linux, Steam Deck
|
||||||
|
'/usr/lib/x86_64-linux-gnu/libjemalloc.so.2', // Debian/Ubuntu
|
||||||
|
'/usr/lib64/libjemalloc.so.2', // Fedora/RHEL
|
||||||
|
'/usr/lib/libjemalloc.so', // Generic fallback
|
||||||
|
'/usr/lib/x86_64-linux-gnu/libjemalloc.so', // Debian/Ubuntu fallback
|
||||||
|
'/usr/lib64/libjemalloc.so' // Fedora/RHEL fallback
|
||||||
|
];
|
||||||
|
|
||||||
|
let jemalloc = null;
|
||||||
|
for (const p of jemallocPaths) {
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
jemalloc = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jemalloc) {
|
||||||
|
env.LD_PRELOAD = jemalloc + (env.LD_PRELOAD ? ':' + env.LD_PRELOAD : '');
|
||||||
|
console.log(`Linux: Using jemalloc allocator for stability (${jemalloc})`);
|
||||||
|
} else {
|
||||||
|
// Try auto-install
|
||||||
|
if (process.env.HYTALE_AUTO_INSTALL_JEMALLOC !== '0') {
|
||||||
|
const installed = await tryInstallJemalloc();
|
||||||
|
if (installed) {
|
||||||
|
for (const p of jemallocPaths) {
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
jemalloc = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jemalloc) {
|
||||||
|
env.LD_PRELOAD = jemalloc + (env.LD_PRELOAD ? ':' + env.LD_PRELOAD : '');
|
||||||
|
console.log(`Linux: Using jemalloc after auto-install (${jemalloc})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jemalloc) {
|
||||||
|
env.MALLOC_CHECK_ = '0';
|
||||||
|
console.log('Linux: jemalloc not found - install with: sudo pacman -S jemalloc (Arch) or sudo apt install libjemalloc2 (Debian/Ubuntu)');
|
||||||
|
console.log('Linux: Using fallback MALLOC_CHECK_=0 (may still crash on glibc 2.41+)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Linux: jemalloc disabled by HYTALE_NO_JEMALLOC=1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: log LD_PRELOAD before spawn
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
console.log(`Linux: LD_PRELOAD = ${env.LD_PRELOAD || '(not set)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let spawnOptions = {
|
let spawnOptions = {
|
||||||
@@ -142,11 +402,23 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
spawnOptions.shell = false;
|
spawnOptions.shell = false;
|
||||||
spawnOptions.windowsHide = true;
|
spawnOptions.windowsHide = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = spawn(clientPath, args, spawnOptions);
|
let child;
|
||||||
|
|
||||||
|
// Linux: Use shell with inline LD_PRELOAD for maximum compatibility
|
||||||
|
if (process.platform === 'linux' && env.LD_PRELOAD) {
|
||||||
|
const quotedArgs = args.map(a => `"${a.replace(/"/g, '\\"')}"`).join(' ');
|
||||||
|
const shellCmd = `LD_PRELOAD="${env.LD_PRELOAD}" "${clientPath}" ${quotedArgs}`;
|
||||||
|
console.log(`Linux: Launching via shell with LD_PRELOAD`);
|
||||||
|
|
||||||
|
spawnOptions.shell = '/bin/bash';
|
||||||
|
child = spawn(shellCmd, [], spawnOptions);
|
||||||
|
} else {
|
||||||
|
child = spawn(clientPath, args, spawnOptions);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Game process started with PID: ${child.pid}`);
|
console.log(`Game process started with PID: ${child.pid}`);
|
||||||
|
|
||||||
@@ -183,6 +455,7 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Monitor game process status in background
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!hasExited) {
|
if (!hasExited) {
|
||||||
console.log('Game appears to be running successfully');
|
console.log('Game appears to be running successfully');
|
||||||
@@ -195,6 +468,7 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
|
// Return immediately, don't wait for setTimeout
|
||||||
return { success: true, installed: true, launched: true, pid: child.pid };
|
return { success: true, installed: true, launched: true, pid: child.pid };
|
||||||
} catch (spawnError) {
|
} catch (spawnError) {
|
||||||
console.error(`Error spawning game process: ${spawnError.message}`);
|
console.error(`Error spawning game process: ${spawnError.message}`);
|
||||||
@@ -205,23 +479,23 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function launchGameWithVersionCheck(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride) {
|
async function launchGameWithVersionCheck(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
|
||||||
try {
|
try {
|
||||||
|
const branch = branchOverride || loadVersionBranch();
|
||||||
|
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback('Checking for updates...', 0, null, null, null);
|
progressCallback('Checking for updates...', 0, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [installedVersion, latestVersion] = await Promise.all([
|
const installedVersion = loadVersionClient();
|
||||||
getInstalledClientVersion(),
|
const latestVersion = await getLatestClientVersion(branch);
|
||||||
getLatestClientVersion()
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log(`Installed version: ${installedVersion}, Latest version: ${latestVersion}`);
|
console.log(`Installed version: ${installedVersion}, Latest version: ${latestVersion} (branch: ${branch})`);
|
||||||
|
|
||||||
let needsUpdate = false;
|
let needsUpdate = false;
|
||||||
if (installedVersion && latestVersion && installedVersion !== latestVersion) {
|
if (!installedVersion || installedVersion !== latestVersion) {
|
||||||
needsUpdate = true;
|
needsUpdate = true;
|
||||||
console.log('Version mismatch detected, update required');
|
console.log('Version mismatch or not installed, update required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
@@ -230,19 +504,19 @@ async function launchGameWithVersionCheck(playerName = 'Player', progressCallbac
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customAppDir = getResolvedAppDir(installPathOverride);
|
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 customToolsDir = path.join(customAppDir, 'butler');
|
||||||
const customCacheDir = path.join(customAppDir, 'cache');
|
const customCacheDir = path.join(customAppDir, 'cache');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateGameFiles(latestVersion, progressCallback, customGameDir, customToolsDir, customCacheDir);
|
await updateGameFiles(latestVersion, progressCallback, customGameDir, customToolsDir, customCacheDir, branch);
|
||||||
console.log('Game updated successfully, waiting before launch...');
|
console.log('Game updated successfully, patching will be forced on launch...');
|
||||||
|
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback('Preparing game launch...', 90, null, null, null);
|
progressCallback('Preparing game launch...', 90, null, null, null);
|
||||||
}
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
console.error('Update failed:', updateError);
|
console.error('Update failed:', updateError);
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
@@ -256,17 +530,26 @@ async function launchGameWithVersionCheck(playerName = 'Player', progressCallbac
|
|||||||
progressCallback('Launching game...', 80, null, null, null);
|
progressCallback('Launching game...', 80, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await launchGame(playerName, progressCallback, javaPathOverride, installPathOverride);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error in version check and launch:', error);
|
console.error('Error in version check and launch:', error);
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback(`Error: ${error.message}`, -1, null, null, null);
|
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' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
launchGame,
|
launchGame,
|
||||||
launchGameWithVersionCheck
|
launchGameWithVersionCheck
|
||||||
};
|
};
|
||||||
@@ -3,62 +3,185 @@ const path = require('path');
|
|||||||
const { execFile } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
|
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
|
||||||
const { getOS, getArch } = require('../utils/platformUtils');
|
const { getOS, getArch } = require('../utils/platformUtils');
|
||||||
const { downloadFile } = require('../utils/fileManager');
|
const { downloadFile, retryDownload, retryStalledDownload, MAX_AUTOMATIC_STALL_RETRIES } = require('../utils/fileManager');
|
||||||
const { getLatestClientVersion, getInstalledClientVersion } = require('../services/versionManager');
|
const { getLatestClientVersion, getInstalledClientVersion } = require('../services/versionManager');
|
||||||
const { installButler } = require('./butlerManager');
|
const { installButler } = require('./butlerManager');
|
||||||
const { checkAndInstallMultiClient } = require('./multiClientManager');
|
|
||||||
const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager');
|
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 { resolveJavaPath, detectSystemJava, downloadJRE, getJavaExec, getBundledJavaPath } = require('./javaManager');
|
||||||
|
const { getUserDataPath, migrateUserDataToCentralized } = require('../utils/userDataMigration');
|
||||||
|
|
||||||
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 osName = getOS();
|
||||||
const arch = getArch();
|
const arch = getArch();
|
||||||
const url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${version}/0/${fileName}`;
|
|
||||||
|
|
||||||
const dest = path.join(cacheDir, fileName);
|
|
||||||
|
|
||||||
if (fs.existsSync(dest)) {
|
if (osName === 'darwin' && arch === 'amd64') {
|
||||||
|
throw new Error('Hytale x86_64 Intel Mac Support has not been released yet. Please check back later.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://game-patches.hytale.com/patches/${osName}/${arch}/${branch}/0/${fileName}`;
|
||||||
|
const dest = path.join(cacheDir, `${branch}_${fileName}`);
|
||||||
|
|
||||||
|
// Check if file exists and validate it
|
||||||
|
if (fs.existsSync(dest) && !manualRetry) {
|
||||||
console.log('PWR file found in cache:', dest);
|
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);
|
console.log('Fetching PWR patch file:', url);
|
||||||
await downloadFile(url, dest, progressCallback);
|
|
||||||
console.log('PWR saved to:', dest);
|
|
||||||
|
|
||||||
|
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;
|
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);
|
const butlerPath = await installButler(toolsDir);
|
||||||
|
console.log(`[Butler] Butler path: ${butlerPath}`);
|
||||||
|
console.log(`[Butler] Butler executable: ${fs.existsSync(butlerPath)}`);
|
||||||
|
|
||||||
const gameLatest = gameDir;
|
const gameLatest = gameDir;
|
||||||
const stagingDir = path.join(gameLatest, 'staging-temp');
|
const stagingDir = path.join(gameLatest, 'staging-temp');
|
||||||
|
|
||||||
const clientPath = findClientPath(gameLatest);
|
const clientPath = findClientPath(gameLatest);
|
||||||
|
|
||||||
if (clientPath) {
|
if (clientPath) {
|
||||||
console.log('Game files detected, skipping patch installation.');
|
console.log('Game files detected, skipping patch installation.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(gameLatest)) {
|
// Validate and prepare directories
|
||||||
fs.mkdirSync(gameLatest, { recursive: true });
|
validateGameDirectory(gameLatest, stagingDir);
|
||||||
}
|
|
||||||
if (!fs.existsSync(stagingDir)) {
|
console.log(`[Butler] Game directory validated: ${gameLatest}`);
|
||||||
fs.mkdirSync(stagingDir, { recursive: true });
|
console.log(`[Butler] Staging directory validated: ${stagingDir}`);
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback('Installing game patch...', null, null, null, null);
|
progressCallback('Installing game patch...', null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Installing game patch...');
|
console.log('Installing game patch...');
|
||||||
|
|
||||||
if (!fs.existsSync(butlerPath)) {
|
if (!fs.existsSync(butlerPath)) {
|
||||||
throw new Error(`Butler tool not found at: ${butlerPath}`);
|
throw new Error(`Butler tool not found at: ${butlerPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(pwrFile)) {
|
if (!fs.existsSync(pwrFile)) {
|
||||||
throw new Error(`PWR file not found at: ${pwrFile}`);
|
throw new Error(`PWR file not found at: ${pwrFile}`);
|
||||||
}
|
}
|
||||||
@@ -70,7 +193,9 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
|
|||||||
pwrFile,
|
pwrFile,
|
||||||
gameLatest
|
gameLatest
|
||||||
];
|
];
|
||||||
|
|
||||||
|
console.log(`[Butler] Executing command: ${butlerPath} ${args.join(' ')}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const child = execFile(butlerPath, args, {
|
const child = execFile(butlerPath, args, {
|
||||||
@@ -78,16 +203,97 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
|
|||||||
timeout: 600000
|
timeout: 600000
|
||||||
}, (error, stdout, stderr) => {
|
}, (error, stdout, stderr) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Butler stderr:', stderr);
|
console.error('[Butler] stderr:', stderr);
|
||||||
console.error('Butler stdout:', stdout);
|
console.error('[Butler] stdout:', stdout);
|
||||||
reject(new Error(`Patch installation failed: ${error.message}${stderr ? '\n' + stderr : ''}`));
|
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 {
|
} else {
|
||||||
|
console.log('[Butler] Patch installation completed successfully');
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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)) {
|
if (fs.existsSync(stagingDir)) {
|
||||||
@@ -100,161 +306,146 @@ async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir
|
|||||||
console.log('Installation complete');
|
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 tempUpdateDir;
|
||||||
|
const branch = branchOverride || loadVersionBranch();
|
||||||
|
const installPath = path.dirname(path.dirname(path.dirname(path.dirname(gameDir))));
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const oldBranch = config.version_branch || 'release';
|
||||||
|
console.log(`[UpdateGameFiles] Switching from ${oldBranch} to ${branch}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (progressCallback) {
|
// NEW 2.1.2: Ensure UserData migration to centralized location
|
||||||
progressCallback('Updating game files...', 0, null, null, null);
|
try {
|
||||||
|
console.log('[UpdateGameFiles] Ensuring UserData migration...');
|
||||||
|
const migrationResult = await migrateUserDataToCentralized();
|
||||||
|
if (migrationResult.migrated) {
|
||||||
|
console.log('[UpdateGameFiles] ✓ UserData migrated to centralized location');
|
||||||
|
} else if (migrationResult.alreadyMigrated) {
|
||||||
|
console.log('[UpdateGameFiles] ✓ UserData already in centralized location');
|
||||||
|
}
|
||||||
|
} catch (migrationError) {
|
||||||
|
console.warn('[UpdateGameFiles] UserData migration warning:', migrationError.message);
|
||||||
}
|
}
|
||||||
console.log(`Updating game files to version: ${newVersion}`);
|
|
||||||
|
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');
|
tempUpdateDir = path.join(gameDir, '..', 'temp_update');
|
||||||
|
|
||||||
if (fs.existsSync(tempUpdateDir)) {
|
if (fs.existsSync(tempUpdateDir)) {
|
||||||
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
|
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
fs.mkdirSync(tempUpdateDir, { recursive: true });
|
fs.mkdirSync(tempUpdateDir, { recursive: true });
|
||||||
|
|
||||||
if (progressCallback) {
|
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) {
|
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) {
|
if (progressCallback) {
|
||||||
progressCallback('Replacing game files...', 80, null, null, null);
|
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)) {
|
if (fs.existsSync(gameDir)) {
|
||||||
console.log('Removing old game files...');
|
console.log('Removing old game files...');
|
||||||
fs.rmSync(gameDir, { recursive: true, force: true });
|
let retries = 3;
|
||||||
}
|
while (retries > 0) {
|
||||||
|
try {
|
||||||
fs.renameSync(tempUpdateDir, gameDir);
|
fs.rmSync(gameDir, { recursive: true, force: true });
|
||||||
|
break;
|
||||||
const multiResult = await checkAndInstallMultiClient(gameDir, progressCallback);
|
} catch (err) {
|
||||||
console.log('Multiplayer-client check result after update:', multiResult);
|
if ((err.code === 'EPERM' || err.code === 'EBUSY') && retries > 0) {
|
||||||
|
retries--;
|
||||||
const homeUIResult = await downloadAndReplaceHomePageUI(gameDir, progressCallback);
|
console.log(`[UpdateGameFiles] Removal failed with ${err.code}, retrying in 1s... (${retries} retries left)`);
|
||||||
console.log('HomePage.ui update result after update:', homeUIResult);
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
} else {
|
||||||
const logoResult = await downloadAndReplaceLogo(gameDir, progressCallback);
|
throw err;
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
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(userDataBackup, newUserDataPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fs.renameSync(tempUpdateDir, gameDir);
|
||||||
|
|
||||||
|
const homeUIResult = await downloadAndReplaceHomePageUI(gameDir, progressCallback);
|
||||||
|
console.log('HomePage.ui update result after update:', homeUIResult);
|
||||||
|
|
||||||
|
const logoResult = await downloadAndReplaceLogo(gameDir, progressCallback);
|
||||||
|
console.log('Logo@2x.png update result after update:', logoResult);
|
||||||
|
|
||||||
|
// NEW 2.1.2: No longer create UserData in game installation
|
||||||
|
// UserData is now in centralized location (getUserDataPath())
|
||||||
|
console.log('[UpdateGameFiles] UserData is managed in centralized location');
|
||||||
|
|
||||||
console.log(`Game files updated successfully to version: ${newVersion}`);
|
console.log(`Game files updated successfully to version: ${newVersion}`);
|
||||||
|
|
||||||
if (userDataBackup && fs.existsSync(userDataBackup)) {
|
// Save the updated version and branch to config
|
||||||
try {
|
saveVersionClient(newVersion);
|
||||||
fs.rmSync(userDataBackup, { recursive: true, force: true });
|
const { saveVersionBranch } = require('../core/config');
|
||||||
console.log('UserData backup cleaned up');
|
saveVersionBranch(branch);
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Could not clean up UserData backup:', cleanupError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Waiting for file system sync...');
|
console.log('Waiting for file system sync...');
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback('Game update completed', 100, null, null, null);
|
progressCallback('Game update completed', 100, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, updated: true, version: newVersion };
|
return { success: true, updated: true, version: newVersion };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating game files:', error);
|
console.error('Error updating game files:', error);
|
||||||
|
|
||||||
if (userDataBackup && fs.existsSync(userDataBackup)) {
|
|
||||||
try {
|
|
||||||
fs.rmSync(userDataBackup, { recursive: true, force: true });
|
|
||||||
console.log('UserData backup cleaned up after error');
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Could not clean up UserData backup:', cleanupError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tempUpdateDir && fs.existsSync(tempUpdateDir)) {
|
if (tempUpdateDir && fs.existsSync(tempUpdateDir)) {
|
||||||
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
|
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to update game files: ${error.message}`);
|
throw new Error(`Failed to update game files: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGameInstalled() {
|
function isGameInstalled(branchOverride = null) {
|
||||||
|
const branch = branchOverride || loadVersionBranch();
|
||||||
const appDir = getResolvedAppDir();
|
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);
|
const clientPath = findClientPath(gameDir);
|
||||||
return clientPath !== null;
|
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 customAppDir = getResolvedAppDir(installPathOverride);
|
||||||
const customCacheDir = path.join(customAppDir, 'cache');
|
const customCacheDir = path.join(customAppDir, 'cache');
|
||||||
const customToolsDir = path.join(customAppDir, 'butler');
|
const customToolsDir = path.join(customAppDir, 'butler');
|
||||||
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
|
const customGameDir = path.join(customAppDir, branch, 'package', 'game', 'latest');
|
||||||
const customJreDir = path.join(customAppDir, 'release', 'package', 'jre', 'latest');
|
const customJreDir = path.join(customAppDir, branch, 'package', 'jre', 'latest');
|
||||||
const userDataDir = path.join(customGameDir, 'Client', 'UserData');
|
|
||||||
|
// NEW 2.1.2: Ensure UserData migration to centralized location
|
||||||
|
try {
|
||||||
|
console.log('[InstallGame] Ensuring UserData migration...');
|
||||||
|
const migrationResult = await migrateUserDataToCentralized();
|
||||||
|
if (migrationResult.migrated) {
|
||||||
|
console.log('[InstallGame] ✓ UserData migrated to centralized location');
|
||||||
|
} else if (migrationResult.alreadyMigrated) {
|
||||||
|
console.log('[InstallGame] ✓ UserData already in centralized location');
|
||||||
|
}
|
||||||
|
} catch (migrationError) {
|
||||||
|
console.warn('[InstallGame] UserData migration warning:', migrationError.message);
|
||||||
|
}
|
||||||
|
|
||||||
[customAppDir, customCacheDir, customToolsDir].forEach(dir => {
|
[customAppDir, customCacheDir, customToolsDir].forEach(dir => {
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
@@ -262,10 +453,6 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!fs.existsSync(userDataDir)) {
|
|
||||||
fs.mkdirSync(userDataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
saveUsername(playerName);
|
saveUsername(playerName);
|
||||||
if (installPathOverride) {
|
if (installPathOverride) {
|
||||||
saveInstallPath(installPathOverride);
|
saveInstallPath(installPathOverride);
|
||||||
@@ -296,9 +483,17 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
|
|||||||
try {
|
try {
|
||||||
await downloadJRE(progressCallback, customCacheDir, customJreDir);
|
await downloadJRE(progressCallback, customCacheDir, customJreDir);
|
||||||
} catch (error) {
|
} 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();
|
const fallback = await detectSystemJava();
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
javaBin = fallback;
|
javaBin = fallback;
|
||||||
|
console.log('[Install] Using system Java as fallback');
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -312,36 +507,61 @@ async function installGame(playerName = 'Player', progressCallback, javaPathOver
|
|||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback('Fetching game files...', null, null, null, null);
|
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 latestVersion = await getLatestClientVersion(branch);
|
||||||
const pwrFile = await downloadPWR('release', latestVersion, progressCallback, customCacheDir);
|
let pwrFile;
|
||||||
await applyPWR(pwrFile, progressCallback, customGameDir, customToolsDir);
|
try {
|
||||||
|
pwrFile = await downloadPWR(branch, latestVersion, progressCallback, customCacheDir);
|
||||||
const multiResult = await checkAndInstallMultiClient(customGameDir, progressCallback);
|
|
||||||
console.log('Multiplayer check result:', multiResult);
|
// 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);
|
const homeUIResult = await downloadAndReplaceHomePageUI(customGameDir, progressCallback);
|
||||||
console.log('HomePage.ui update result after installation:', homeUIResult);
|
console.log('HomePage.ui update result after installation:', homeUIResult);
|
||||||
|
|
||||||
const logoResult = await downloadAndReplaceLogo(customGameDir, progressCallback);
|
const logoResult = await downloadAndReplaceLogo(customGameDir, progressCallback);
|
||||||
console.log('Logo@2x.png update result after installation:', logoResult);
|
console.log('Logo@2x.png update result after installation:', logoResult);
|
||||||
|
|
||||||
|
// NEW 2.1.2: No longer create UserData in game installation
|
||||||
|
// UserData is managed in centralized location (getUserDataPath())
|
||||||
|
console.log('[InstallGame] UserData is managed in centralized location');
|
||||||
|
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback('Installation complete', 100, null, null, null);
|
progressCallback('Installation complete', 100, null, null, null);
|
||||||
}
|
}
|
||||||
console.log('Game installation completed successfully');
|
console.log('Game installation completed successfully');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
installed: true,
|
installed: true
|
||||||
multiClient: multiResult
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uninstallGame() {
|
async function uninstallGame() {
|
||||||
const appDir = getResolvedAppDir();
|
const appDir = getResolvedAppDir();
|
||||||
|
|
||||||
if (!fs.existsSync(appDir)) {
|
if (!fs.existsSync(appDir)) {
|
||||||
throw new Error('Game is not installed');
|
throw new Error('Game is not installed');
|
||||||
}
|
}
|
||||||
@@ -349,7 +569,7 @@ async function uninstallGame() {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(appDir, { recursive: true, force: true });
|
fs.rmSync(appDir, { recursive: true, force: true });
|
||||||
console.log('Game uninstalled successfully - removed entire HytaleF2P folder');
|
console.log('Game uninstalled successfully - removed entire HytaleF2P folder');
|
||||||
|
|
||||||
if (fs.existsSync(CONFIG_FILE)) {
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
delete config.installPath;
|
delete config.installPath;
|
||||||
@@ -360,34 +580,36 @@ async function uninstallGame() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkExistingGameInstallation() {
|
function checkExistingGameInstallation(branchOverride = null) {
|
||||||
try {
|
try {
|
||||||
|
const branch = branchOverride || loadVersionBranch();
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
if (!config.installPath || !config.installPath.trim()) {
|
if (!config.installPath || !config.installPath.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const installPath = config.installPath.trim();
|
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)) {
|
if (!fs.existsSync(gameDir)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientPath = findClientPath(gameDir);
|
const clientPath = findClientPath(gameDir);
|
||||||
if (!clientPath) {
|
if (!clientPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userDataPath = findUserDataRecursive(gameDir);
|
const userDataPath = findUserDataRecursive(gameDir);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gameDir: gameDir,
|
gameDir: gameDir,
|
||||||
clientPath: clientPath,
|
clientPath: clientPath,
|
||||||
userDataPath: userDataPath,
|
userDataPath: userDataPath,
|
||||||
installPath: installPath,
|
installPath: installPath,
|
||||||
hasUserData: userDataPath && fs.existsSync(userDataPath)
|
hasUserData: userDataPath && fs.existsSync(userDataPath),
|
||||||
|
branch: branch
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking existing game installation:', error);
|
console.error('Error checking existing game installation:', error);
|
||||||
@@ -395,12 +617,157 @@ function checkExistingGameInstallation() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function repairGame(progressCallback, branchOverride = null) {
|
||||||
|
const branch = branchOverride || loadVersionBranch();
|
||||||
|
const appDir = getResolvedAppDir();
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Backing up user data...', 10, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
progressCallback('Removing old game files...', 30, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Game and Cache Directory
|
||||||
|
console.log('Removing corrupted game files...');
|
||||||
|
fs.rmSync(gameDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
const cacheDir = path.join(appDir, 'cache');
|
||||||
|
if (fs.existsSync(cacheDir)) {
|
||||||
|
console.log('Clearing cache directory...');
|
||||||
|
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Reinstalling game files...');
|
||||||
|
|
||||||
|
// Passing null/undefined for overrides to use defaults/saved configs
|
||||||
|
// installGame calls progressCallback internally
|
||||||
|
await installGame('Player', progressCallback, null, null, branch);
|
||||||
|
|
||||||
|
// Restore UserData using new system
|
||||||
|
if (backupPath) {
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Restoring user data...', 90, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback('Repair completed successfully!', 100, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
module.exports = {
|
||||||
downloadPWR,
|
downloadPWR,
|
||||||
|
retryPWRDownload,
|
||||||
applyPWR,
|
applyPWR,
|
||||||
updateGameFiles,
|
updateGameFiles,
|
||||||
isGameInstalled,
|
isGameInstalled,
|
||||||
installGame,
|
installGame,
|
||||||
uninstallGame,
|
uninstallGame,
|
||||||
checkExistingGameInstallation
|
isGameInstalled,
|
||||||
|
installGame,
|
||||||
|
uninstallGame,
|
||||||
|
checkExistingGameInstallation,
|
||||||
|
repairGame
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const tar = require('tar');
|
|||||||
const { expandHome, JRE_DIR } = require('../core/paths');
|
const { expandHome, JRE_DIR } = require('../core/paths');
|
||||||
const { getOS, getArch } = require('../utils/platformUtils');
|
const { getOS, getArch } = require('../utils/platformUtils');
|
||||||
const { loadConfig } = require('../core/config');
|
const { loadConfig } = require('../core/config');
|
||||||
const { downloadFile } = require('../utils/fileManager');
|
const { downloadFile, retryDownload } = require('../utils/fileManager');
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const JAVA_EXECUTABLE = 'java' + (process.platform === 'win32' ? '.exe' : '');
|
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) {
|
async function downloadJRE(progressCallback, cacheDir, jreDir = JRE_DIR) {
|
||||||
if (!fs.existsSync(cacheDir)) {
|
if (!fs.existsSync(cacheDir)) {
|
||||||
fs.mkdirSync(cacheDir, { recursive: true });
|
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);
|
progressCallback('Fetching Java runtime...', null, null, null, null);
|
||||||
}
|
}
|
||||||
console.log('Fetching Java runtime...');
|
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');
|
console.log('Download finished');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,5 +406,6 @@ module.exports = {
|
|||||||
getJavaDetection,
|
getJavaDetection,
|
||||||
downloadJRE,
|
downloadJRE,
|
||||||
extractJRE,
|
extractJRE,
|
||||||
|
retryJREDownload,
|
||||||
JAVA_EXECUTABLE
|
JAVA_EXECUTABLE
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,30 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { getModsPath } = require('../core/paths');
|
const { getOS } = require('../utils/platformUtils');
|
||||||
|
const { getModsPath, getProfilesDir } = require('../core/paths');
|
||||||
const { saveModsToConfig, loadModsFromConfig } = require('../core/config');
|
const { saveModsToConfig, loadModsFromConfig } = require('../core/config');
|
||||||
|
const profileManager = require('./profileManager');
|
||||||
|
|
||||||
|
const API_KEY = "$2a$10$bqk254NMZOWVTzLVJCcxEOmhcyUujKxA5xk.kQCN9q0KNYFJd5b32";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the physical mods path for a specific profile.
|
||||||
|
* Each profile now has its own 'mods' folder.
|
||||||
|
*/
|
||||||
|
function getProfileModsPath(profileId) {
|
||||||
|
const profilesDir = getProfilesDir();
|
||||||
|
if (!profilesDir) return null;
|
||||||
|
|
||||||
|
const profileDir = path.join(profilesDir, profileId);
|
||||||
|
const modsDir = path.join(profileDir, 'mods');
|
||||||
|
|
||||||
|
if (!fs.existsSync(modsDir)) {
|
||||||
|
fs.mkdirSync(modsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return modsDir;
|
||||||
|
}
|
||||||
|
|
||||||
function generateModId(filename) {
|
function generateModId(filename) {
|
||||||
return crypto.createHash('md5').update(filename).digest('hex').substring(0, 8);
|
return crypto.createHash('md5').update(filename).digest('hex').substring(0, 8);
|
||||||
@@ -11,13 +33,13 @@ function generateModId(filename) {
|
|||||||
|
|
||||||
function extractModName(filename) {
|
function extractModName(filename) {
|
||||||
let name = path.parse(filename).name;
|
let name = path.parse(filename).name;
|
||||||
|
|
||||||
name = name.replace(/-v?\d+\.[\d\.]+.*$/i, '');
|
name = name.replace(/-v?\d+\.[\d\.]+.*$/i, '');
|
||||||
name = name.replace(/-\d+\.[\d\.]+.*$/i, '');
|
name = name.replace(/-\d+\.[\d\.]+.*$/i, '');
|
||||||
|
|
||||||
name = name.replace(/[-_]/g, ' ');
|
name = name.replace(/[-_]/g, ' ');
|
||||||
name = name.replace(/\b\w/g, l => l.toUpperCase());
|
name = name.replace(/\b\w/g, l => l.toUpperCase());
|
||||||
|
|
||||||
return name || 'Unknown Mod';
|
return name || 'Unknown Mod';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,77 +48,56 @@ function extractVersion(filename) {
|
|||||||
return versionMatch ? versionMatch[1] : null;
|
return versionMatch ? versionMatch[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to get mods from active profile
|
||||||
|
function getProfileMods() {
|
||||||
|
const profile = profileManager.getActiveProfile();
|
||||||
|
return profile ? (profile.mods || []) : [];
|
||||||
|
}
|
||||||
|
|
||||||
async function loadInstalledMods(modsPath) {
|
async function loadInstalledMods(modsPath) {
|
||||||
try {
|
try {
|
||||||
const configMods = loadModsFromConfig();
|
// Sync first to ensure we detect any manually added mods and paths are correct
|
||||||
const modsMap = new Map();
|
await syncModsForCurrentProfile();
|
||||||
|
|
||||||
|
const activeProfile = profileManager.getActiveProfile();
|
||||||
|
if (!activeProfile) return [];
|
||||||
|
|
||||||
|
const profileMods = activeProfile.mods || [];
|
||||||
|
|
||||||
configMods.forEach(mod => {
|
// Use profile-specific paths
|
||||||
modsMap.set(mod.fileName, mod);
|
const profileModsPath = getProfileModsPath(activeProfile.id);
|
||||||
});
|
const profileDisabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods');
|
||||||
|
|
||||||
if (fs.existsSync(modsPath)) {
|
if (!fs.existsSync(profileModsPath)) fs.mkdirSync(profileModsPath, { recursive: true });
|
||||||
const files = fs.readdirSync(modsPath);
|
if (!fs.existsSync(profileDisabledModsPath)) fs.mkdirSync(profileDisabledModsPath, { recursive: true });
|
||||||
|
|
||||||
for (const file of files) {
|
const validMods = [];
|
||||||
const filePath = path.join(modsPath, file);
|
|
||||||
const stats = fs.statSync(filePath);
|
for (const modConfig of profileMods) {
|
||||||
|
// Check if file exists in either location
|
||||||
if (stats.isFile() && (file.endsWith('.jar') || file.endsWith('.zip'))) {
|
const inEnabled = fs.existsSync(path.join(profileModsPath, modConfig.fileName));
|
||||||
const configMod = modsMap.get(file);
|
const inDisabled = fs.existsSync(path.join(profileDisabledModsPath, modConfig.fileName));
|
||||||
|
|
||||||
const modInfo = {
|
if (inEnabled || inDisabled) {
|
||||||
id: configMod?.id || generateModId(file),
|
validMods.push({
|
||||||
name: configMod?.name || extractModName(file),
|
...modConfig,
|
||||||
version: configMod?.version || extractVersion(file) || '1.0.0',
|
// Set filePath based on physical location
|
||||||
description: configMod?.description || 'Installed mod',
|
filePath: inEnabled ? path.join(profileModsPath, modConfig.fileName) : path.join(profileDisabledModsPath, modConfig.fileName),
|
||||||
author: configMod?.author || 'Unknown',
|
enabled: modConfig.enabled !== false // Default true
|
||||||
enabled: true,
|
});
|
||||||
filePath: filePath,
|
} else {
|
||||||
fileName: file,
|
console.warn(`[ModManager] Mod ${modConfig.fileName} listed in profile but not found on disk.`);
|
||||||
fileSize: configMod?.fileSize || stats.size,
|
// Include it so user can see it's missing or remove it
|
||||||
dateInstalled: configMod?.dateInstalled || stats.birthtime || stats.mtime,
|
validMods.push({
|
||||||
curseForgeId: configMod?.curseForgeId,
|
...modConfig,
|
||||||
curseForgeFileId: configMod?.curseForgeFileId
|
filePath: null,
|
||||||
};
|
missing: true,
|
||||||
|
enabled: modConfig.enabled !== false
|
||||||
modsMap.set(file, modInfo);
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods');
|
return validMods;
|
||||||
if (fs.existsSync(disabledModsPath)) {
|
|
||||||
const files = fs.readdirSync(disabledModsPath);
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(disabledModsPath, file);
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
|
|
||||||
if (stats.isFile() && (file.endsWith('.jar') || file.endsWith('.zip'))) {
|
|
||||||
const configMod = modsMap.get(file);
|
|
||||||
|
|
||||||
const modInfo = {
|
|
||||||
id: configMod?.id || generateModId(file),
|
|
||||||
name: configMod?.name || extractModName(file),
|
|
||||||
version: configMod?.version || extractVersion(file) || '1.0.0',
|
|
||||||
description: configMod?.description || 'Disabled mod',
|
|
||||||
author: configMod?.author || 'Unknown',
|
|
||||||
enabled: false,
|
|
||||||
filePath: filePath,
|
|
||||||
fileName: file,
|
|
||||||
fileSize: configMod?.fileSize || stats.size,
|
|
||||||
dateInstalled: configMod?.dateInstalled || stats.birthtime || stats.mtime,
|
|
||||||
curseForgeId: configMod?.curseForgeId,
|
|
||||||
curseForgeFileId: configMod?.curseForgeFileId
|
|
||||||
};
|
|
||||||
|
|
||||||
modsMap.set(file, modInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(modsMap.values());
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading installed mods:', error);
|
console.error('Error loading installed mods:', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -105,44 +106,48 @@ async function loadInstalledMods(modsPath) {
|
|||||||
|
|
||||||
async function downloadMod(modInfo) {
|
async function downloadMod(modInfo) {
|
||||||
try {
|
try {
|
||||||
const modsPath = await getModsPath();
|
const activeProfile = profileManager.getActiveProfile();
|
||||||
|
if (!activeProfile) throw new Error('No active profile to save mod to');
|
||||||
|
|
||||||
|
const modsPath = getProfileModsPath(activeProfile.id);
|
||||||
|
if (!modsPath) throw new Error('Could not determine profile mods path');
|
||||||
|
|
||||||
if (!modInfo.downloadUrl && !modInfo.fileId) {
|
if (!modInfo.downloadUrl && !modInfo.fileId) {
|
||||||
throw new Error('No download URL or file ID provided');
|
throw new Error('No download URL or file ID provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
let downloadUrl = modInfo.downloadUrl;
|
let downloadUrl = modInfo.downloadUrl;
|
||||||
|
|
||||||
if (!downloadUrl && modInfo.fileId && modInfo.modId) {
|
if (!downloadUrl && modInfo.fileId && modInfo.modId) {
|
||||||
const response = await axios.get(`https://api.curseforge.com/v1/mods/${modInfo.modId}/files/${modInfo.fileId}`, {
|
const response = await axios.get(`https://api.curseforge.com/v1/mods/${modInfo.modId || modInfo.curseForgeId}/files/${modInfo.fileId || modInfo.curseForgeFileId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': modInfo.apiKey,
|
'x-api-key': modInfo.apiKey || API_KEY,
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
downloadUrl = response.data.data.downloadUrl;
|
downloadUrl = response.data.data.downloadUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!downloadUrl) {
|
if (!downloadUrl) {
|
||||||
throw new Error('Could not determine download URL');
|
throw new Error('Could not determine download URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = modInfo.fileName || `mod-${modInfo.modId}.jar`;
|
const fileName = modInfo.fileName || `mod-${modInfo.modId}.jar`;
|
||||||
const filePath = path.join(modsPath, fileName);
|
const filePath = path.join(modsPath, fileName);
|
||||||
|
|
||||||
const response = await axios({
|
const response = await axios({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
url: downloadUrl,
|
url: downloadUrl,
|
||||||
responseType: 'stream'
|
responseType: 'stream'
|
||||||
});
|
});
|
||||||
|
|
||||||
const writer = fs.createWriteStream(filePath);
|
const writer = fs.createWriteStream(filePath);
|
||||||
response.data.pipe(writer);
|
response.data.pipe(writer);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
writer.on('finish', () => {
|
writer.on('finish', () => {
|
||||||
const configMods = loadModsFromConfig();
|
// Update Active Profile
|
||||||
const newMod = {
|
const newMod = {
|
||||||
id: modInfo.id || generateModId(fileName),
|
id: modInfo.id || generateModId(fileName),
|
||||||
name: modInfo.name || extractModName(fileName),
|
name: modInfo.name || extractModName(fileName),
|
||||||
@@ -156,10 +161,10 @@ async function downloadMod(modInfo) {
|
|||||||
curseForgeId: modInfo.modId,
|
curseForgeId: modInfo.modId,
|
||||||
curseForgeFileId: modInfo.fileId
|
curseForgeFileId: modInfo.fileId
|
||||||
};
|
};
|
||||||
|
|
||||||
configMods.push(newMod);
|
const updatedMods = [...(activeProfile.mods || []), newMod];
|
||||||
saveModsToConfig(configMods);
|
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
@@ -169,7 +174,7 @@ async function downloadMod(modInfo) {
|
|||||||
});
|
});
|
||||||
writer.on('error', reject);
|
writer.on('error', reject);
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading mod:', error);
|
console.error('Error downloading mod:', error);
|
||||||
return {
|
return {
|
||||||
@@ -181,36 +186,42 @@ async function downloadMod(modInfo) {
|
|||||||
|
|
||||||
async function uninstallMod(modId, modsPath) {
|
async function uninstallMod(modId, modsPath) {
|
||||||
try {
|
try {
|
||||||
const configMods = loadModsFromConfig();
|
const activeProfile = profileManager.getActiveProfile();
|
||||||
const mod = configMods.find(m => m.id === modId);
|
if (!activeProfile) throw new Error('No active profile');
|
||||||
|
|
||||||
|
const profileMods = activeProfile.mods || [];
|
||||||
|
const mod = profileMods.find(m => m.id === modId);
|
||||||
|
|
||||||
if (!mod) {
|
if (!mod) {
|
||||||
throw new Error('Mod not found in config');
|
throw new Error('Mod not found in profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use profile paths
|
||||||
|
const profileModsPath = getProfileModsPath(activeProfile.id);
|
||||||
|
const disabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods');
|
||||||
|
|
||||||
const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods');
|
const enabledPath = path.join(profileModsPath, mod.fileName);
|
||||||
const enabledPath = path.join(modsPath, mod.fileName);
|
|
||||||
const disabledPath = path.join(disabledModsPath, mod.fileName);
|
const disabledPath = path.join(disabledModsPath, mod.fileName);
|
||||||
|
|
||||||
let fileRemoved = false;
|
let fileRemoved = false;
|
||||||
|
// Try to remove file from both locations to be safe
|
||||||
if (fs.existsSync(enabledPath)) {
|
if (fs.existsSync(enabledPath)) {
|
||||||
fs.unlinkSync(enabledPath);
|
fs.unlinkSync(enabledPath);
|
||||||
fileRemoved = true;
|
fileRemoved = true;
|
||||||
console.log('Removed mod from Mods folder:', enabledPath);
|
|
||||||
} else if (fs.existsSync(disabledPath)) {
|
|
||||||
fs.unlinkSync(disabledPath);
|
|
||||||
fileRemoved = true;
|
|
||||||
console.log('Removed mod from DisabledMods folder:', disabledPath);
|
|
||||||
}
|
}
|
||||||
|
if (fs.existsSync(disabledPath)) {
|
||||||
|
try { fs.unlinkSync(disabledPath); fileRemoved = true; } catch (e) { }
|
||||||
|
}
|
||||||
|
|
||||||
if (!fileRemoved) {
|
if (!fileRemoved) {
|
||||||
console.warn('Mod file not found on filesystem, removing from config anyway');
|
console.warn('Mod file not found on filesystem, removing from profile anyway');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedMods = configMods.filter(m => m.id !== modId);
|
const updatedMods = profileMods.filter(m => m.id !== modId);
|
||||||
saveModsToConfig(updatedMods);
|
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
||||||
console.log('Mod removed from config.json');
|
|
||||||
|
console.log('Mod removed from profile');
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uninstalling mod:', error);
|
console.error('Error uninstalling mod:', error);
|
||||||
@@ -223,38 +234,47 @@ async function uninstallMod(modId, modsPath) {
|
|||||||
|
|
||||||
async function toggleMod(modId, modsPath) {
|
async function toggleMod(modId, modsPath) {
|
||||||
try {
|
try {
|
||||||
const mods = await loadInstalledMods(modsPath);
|
const activeProfile = profileManager.getActiveProfile();
|
||||||
const mod = mods.find(m => m.id === modId);
|
if (!activeProfile) throw new Error('No active profile');
|
||||||
|
|
||||||
|
const profileMods = activeProfile.mods || [];
|
||||||
|
const modIndex = profileMods.findIndex(m => m.id === modId);
|
||||||
|
|
||||||
|
if (modIndex === -1) {
|
||||||
|
throw new Error('Mod not found in profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod = profileMods[modIndex];
|
||||||
|
const newEnabled = !mod.enabled; // Toggle
|
||||||
|
|
||||||
|
// Update Profile First
|
||||||
|
const updatedMods = [...profileMods];
|
||||||
|
updatedMods[modIndex] = { ...mod, enabled: newEnabled };
|
||||||
|
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
||||||
|
|
||||||
|
// Move file between Profile/Mods and Profile/DisabledMods
|
||||||
|
const profileModsPath = getProfileModsPath(activeProfile.id);
|
||||||
|
const disabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods');
|
||||||
|
|
||||||
if (!mod) {
|
if (!fs.existsSync(disabledModsPath)) fs.mkdirSync(disabledModsPath, { recursive: true });
|
||||||
throw new Error('Mod not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const disabledModsPath = path.join(path.dirname(modsPath), 'DisabledMods');
|
const currentPath = mod.enabled ? path.join(profileModsPath, mod.fileName) : path.join(disabledModsPath, mod.fileName);
|
||||||
if (!fs.existsSync(disabledModsPath)) {
|
const targetDir = newEnabled ? profileModsPath : disabledModsPath;
|
||||||
fs.mkdirSync(disabledModsPath, { recursive: true });
|
const targetPath = path.join(targetDir, mod.fileName);
|
||||||
}
|
|
||||||
|
|
||||||
const currentPath = mod.filePath;
|
if (fs.existsSync(currentPath)) {
|
||||||
let newPath, newEnabled;
|
fs.renameSync(currentPath, targetPath);
|
||||||
|
|
||||||
if (mod.enabled) {
|
|
||||||
newPath = path.join(disabledModsPath, path.basename(currentPath));
|
|
||||||
newEnabled = false;
|
|
||||||
} else {
|
} else {
|
||||||
newPath = path.join(modsPath, path.basename(currentPath));
|
// Fallback: check if it's already in target?
|
||||||
newEnabled = true;
|
if (fs.existsSync(targetPath)) {
|
||||||
|
console.log(`[ModManager] Mod ${mod.fileName} is already in the correct state`);
|
||||||
|
} else {
|
||||||
|
// Try finding it
|
||||||
|
const altPath = mod.enabled ? path.join(disabledModsPath, mod.fileName) : path.join(profileModsPath, mod.fileName);
|
||||||
|
if (fs.existsSync(altPath)) fs.renameSync(altPath, targetPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.renameSync(currentPath, newPath);
|
|
||||||
|
|
||||||
const configMods = loadModsFromConfig();
|
|
||||||
const configModIndex = configMods.findIndex(m => m.id === modId);
|
|
||||||
if (configModIndex !== -1) {
|
|
||||||
configMods[configModIndex].enabled = newEnabled;
|
|
||||||
saveModsToConfig(configMods);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, enabled: newEnabled };
|
return { success: true, enabled: newEnabled };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling mod:', error);
|
console.error('Error toggling mod:', error);
|
||||||
@@ -265,11 +285,218 @@ async function toggleMod(modId, modsPath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncModsForCurrentProfile() {
|
||||||
|
try {
|
||||||
|
const activeProfile = profileManager.getActiveProfile();
|
||||||
|
if (!activeProfile) {
|
||||||
|
console.warn('No active profile found during mod sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ModManager] Syncing mods for profile: ${activeProfile.name} (${activeProfile.id})`);
|
||||||
|
|
||||||
|
// 1. Resolve Paths
|
||||||
|
// globalModsPath is the one the game uses (symlink target)
|
||||||
|
const globalModsPath = await getModsPath();
|
||||||
|
// profileModsPath is the real storage for this profile
|
||||||
|
const profileModsPath = getProfileModsPath(activeProfile.id);
|
||||||
|
const profileDisabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods');
|
||||||
|
|
||||||
|
if (!fs.existsSync(profileDisabledModsPath)) {
|
||||||
|
fs.mkdirSync(profileDisabledModsPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Symlink / Migration Logic
|
||||||
|
let needsLink = false;
|
||||||
|
let globalStats = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
globalStats = fs.lstatSync(globalModsPath);
|
||||||
|
} catch (e) {
|
||||||
|
// Path doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalStats) {
|
||||||
|
if (globalStats.isSymbolicLink()) {
|
||||||
|
const linkTarget = fs.readlinkSync(globalModsPath);
|
||||||
|
// Normalize paths for comparison
|
||||||
|
if (path.resolve(linkTarget) !== path.resolve(profileModsPath)) {
|
||||||
|
console.log(`[ModManager] Updating symlink from ${linkTarget} to ${profileModsPath}`);
|
||||||
|
fs.unlinkSync(globalModsPath);
|
||||||
|
needsLink = true;
|
||||||
|
}
|
||||||
|
} else if (globalStats.isDirectory()) {
|
||||||
|
// MIGRATION: It's a real directory. Move contents to profile.
|
||||||
|
console.log('[ModManager] Migrating global mods folder to profile folder...');
|
||||||
|
const files = fs.readdirSync(globalModsPath);
|
||||||
|
for (const file of files) {
|
||||||
|
const src = path.join(globalModsPath, file);
|
||||||
|
const dest = path.join(profileModsPath, file);
|
||||||
|
// Only move if dest doesn't exist to avoid overwriting
|
||||||
|
if (!fs.existsSync(dest)) {
|
||||||
|
fs.renameSync(src, dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also migrate DisabledMods if it exists globally
|
||||||
|
const globalDisabledPath = path.join(path.dirname(globalModsPath), 'DisabledMods');
|
||||||
|
if (fs.existsSync(globalDisabledPath) && fs.lstatSync(globalDisabledPath).isDirectory()) {
|
||||||
|
const dFiles = fs.readdirSync(globalDisabledPath);
|
||||||
|
for (const file of dFiles) {
|
||||||
|
const src = path.join(globalDisabledPath, file);
|
||||||
|
const dest = path.join(profileDisabledModsPath, file);
|
||||||
|
if (!fs.existsSync(dest)) {
|
||||||
|
fs.renameSync(src, dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We can remove global DisabledMods now, as it's not used by game
|
||||||
|
try { fs.rmSync(globalDisabledPath, { recursive: true, force: true }); } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the directory so we can link it
|
||||||
|
try {
|
||||||
|
let retries = 3;
|
||||||
|
while (retries > 0) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(globalModsPath, { recursive: true, force: true });
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err.code === 'EPERM' || err.code === 'EBUSY') && retries > 0) {
|
||||||
|
retries--;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
needsLink = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to remove global mods dir:', e);
|
||||||
|
// Throw error to stop.
|
||||||
|
throw new Error('Failed to migrate mods directory. Please clear ' + globalModsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
needsLink = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsLink) {
|
||||||
|
console.log(`[ModManager] Creating symlink: ${globalModsPath} -> ${profileModsPath}`);
|
||||||
|
try {
|
||||||
|
const symlinkType = getOS() === 'windows' ? 'junction' : 'dir';
|
||||||
|
fs.symlinkSync(profileModsPath, globalModsPath, symlinkType);
|
||||||
|
} catch (err) {
|
||||||
|
// If we can't create the symlink, try creating the directory first
|
||||||
|
console.error('[ModManager] Failed to create symlink. Falling back to direct folder mode.');
|
||||||
|
console.error(err.message);
|
||||||
|
|
||||||
|
// Fallback: create a real directory so the game still works
|
||||||
|
if (!fs.existsSync(globalModsPath)) {
|
||||||
|
fs.mkdirSync(globalModsPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Auto-Repair (Download missing mods)
|
||||||
|
const profileModsSnapshot = activeProfile.mods || [];
|
||||||
|
for (const mod of profileModsSnapshot) {
|
||||||
|
if (mod.enabled && !mod.manual) {
|
||||||
|
const inEnabled = fs.existsSync(path.join(profileModsPath, mod.fileName));
|
||||||
|
const inDisabled = fs.existsSync(path.join(profileDisabledModsPath, mod.fileName));
|
||||||
|
|
||||||
|
if (!inEnabled && !inDisabled) {
|
||||||
|
if (mod.curseForgeId && (mod.curseForgeFileId || mod.fileId)) {
|
||||||
|
console.log(`[ModManager] Auto-repair: Re-downloading missing mod "${mod.name}"...`);
|
||||||
|
try {
|
||||||
|
await downloadMod({
|
||||||
|
...mod,
|
||||||
|
modId: mod.curseForgeId,
|
||||||
|
fileId: mod.curseForgeFileId || mod.fileId,
|
||||||
|
apiKey: API_KEY
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[ModManager] Auto-repair failed for "${mod.name}": ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Auto-Import (Detect manual drops in the profile folder)
|
||||||
|
const enabledFiles = fs.existsSync(profileModsPath) ? fs.readdirSync(profileModsPath).filter(f => f.endsWith('.jar') || f.endsWith('.zip')) : [];
|
||||||
|
|
||||||
|
let profileMods = activeProfile.mods || [];
|
||||||
|
let profileUpdated = false;
|
||||||
|
|
||||||
|
|
||||||
|
// Anything in this folder belongs to this profile.
|
||||||
|
|
||||||
|
for (const file of enabledFiles) {
|
||||||
|
const isKnown = profileMods.some(m => m.fileName === file);
|
||||||
|
|
||||||
|
if (!isKnown) {
|
||||||
|
console.log(`[ModManager] Auto-importing manual mod: ${file}`);
|
||||||
|
const newMod = {
|
||||||
|
id: generateModId(file),
|
||||||
|
name: extractModName(file),
|
||||||
|
version: 'Unknown',
|
||||||
|
description: 'Manually installed',
|
||||||
|
author: 'Local',
|
||||||
|
enabled: true,
|
||||||
|
fileName: file,
|
||||||
|
fileSize: 0,
|
||||||
|
dateInstalled: new Date().toISOString(),
|
||||||
|
manual: true
|
||||||
|
};
|
||||||
|
profileMods.push(newMod);
|
||||||
|
profileUpdated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileUpdated) {
|
||||||
|
profileManager.updateProfile(activeProfile.id, { mods: profileMods });
|
||||||
|
const updatedProfile = profileManager.getActiveProfile();
|
||||||
|
profileMods = updatedProfile ? (updatedProfile.mods || []) : profileMods;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Enforce Enabled/Disabled State (Move files between Profile/Mods and Profile/DisabledMods)
|
||||||
|
// Note: Since Global/Mods IS Profile/Mods (via symlink), moving out of Profile/Mods disables it for the game.
|
||||||
|
|
||||||
|
const disabledFiles = fs.existsSync(profileDisabledModsPath) ? fs.readdirSync(profileDisabledModsPath).filter(f => f.endsWith('.jar') || f.endsWith('.zip')) : [];
|
||||||
|
const allFiles = new Set([...enabledFiles, ...disabledFiles]);
|
||||||
|
|
||||||
|
for (const fileName of allFiles) {
|
||||||
|
const modConfig = profileMods.find(m => m.fileName === fileName);
|
||||||
|
const shouldBeEnabled = modConfig && modConfig.enabled !== false;
|
||||||
|
|
||||||
|
const currentPath = enabledFiles.includes(fileName) ? path.join(profileModsPath, fileName) : path.join(profileDisabledModsPath, fileName);
|
||||||
|
const targetDir = shouldBeEnabled ? profileModsPath : profileDisabledModsPath;
|
||||||
|
const targetPath = path.join(targetDir, fileName);
|
||||||
|
|
||||||
|
if (path.dirname(currentPath) !== targetDir) {
|
||||||
|
console.log(`[Mod Sync] Moving ${fileName} to ${shouldBeEnabled ? 'Enabled' : 'Disabled'}`);
|
||||||
|
try {
|
||||||
|
fs.renameSync(currentPath, targetPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to move ${fileName}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ModManager] Error syncing mods:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
loadInstalledMods,
|
loadInstalledMods,
|
||||||
downloadMod,
|
downloadMod,
|
||||||
uninstallMod,
|
uninstallMod,
|
||||||
toggleMod,
|
toggleMod,
|
||||||
|
syncModsForCurrentProfile,
|
||||||
generateModId,
|
generateModId,
|
||||||
extractModName,
|
extractModName,
|
||||||
extractVersion
|
extractVersion
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { findClientPath } = require('../core/paths');
|
|
||||||
const { downloadFile } = require('../utils/fileManager');
|
|
||||||
const { getLatestClientVersion, getMultiClientVersion } = require('../services/versionManager');
|
|
||||||
|
|
||||||
async function downloadMultiClient(gameDir, progressCallback) {
|
|
||||||
try {
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
console.log('Multiplayer-client is only available for Windows');
|
|
||||||
return { success: false, reason: 'Platform not supported' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientPath = findClientPath(gameDir);
|
|
||||||
if (!clientPath) {
|
|
||||||
throw new Error('Game client not found. Install game first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Downloading Multiplayer from server...');
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Downloading Multiplayer...', null, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientUrl = 'http://3.10.208.30:3002/client';
|
|
||||||
const tempClientPath = path.join(path.dirname(clientPath), 'HytaleClient_temp.exe');
|
|
||||||
|
|
||||||
await downloadFile(clientUrl, tempClientPath, progressCallback);
|
|
||||||
|
|
||||||
const backupPath = path.join(path.dirname(clientPath), 'HytaleClient_original.exe');
|
|
||||||
if (!fs.existsSync(backupPath)) {
|
|
||||||
fs.copyFileSync(clientPath, backupPath);
|
|
||||||
console.log('Original client backed up');
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.renameSync(tempClientPath, clientPath);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Multiplayer installed', 100, null, null, null);
|
|
||||||
}
|
|
||||||
console.log('Multiplayer installed successfully');
|
|
||||||
|
|
||||||
return { success: true, installed: true };
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error installing Multiplayer:', error);
|
|
||||||
throw new Error(`Failed to install Multiplayer: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkAndInstallMultiClient(gameDir, progressCallback) {
|
|
||||||
try {
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
console.log('Multiplayer check skipped (Windows only)');
|
|
||||||
return { success: true, skipped: true, reason: 'Windows only' };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Checking for Multiplayer availability...');
|
|
||||||
|
|
||||||
const [clientVersion, multiVersion] = await Promise.all([
|
|
||||||
getLatestClientVersion(),
|
|
||||||
getMultiClientVersion()
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!multiVersion) {
|
|
||||||
console.log('Multiplayer not available');
|
|
||||||
return { success: true, skipped: true, reason: 'Multiplayer not available' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientVersion === multiVersion) {
|
|
||||||
console.log(`Versions match (${clientVersion}), installing Multiplayer...`);
|
|
||||||
return await downloadMultiClient(gameDir, progressCallback);
|
|
||||||
} else {
|
|
||||||
console.log(`Version mismatch: client=${clientVersion}, multi=${multiVersion}`);
|
|
||||||
return { success: true, skipped: true, reason: 'Version mismatch' };
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking Multiplayer:', error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
downloadMultiClient,
|
|
||||||
checkAndInstallMultiClient
|
|
||||||
};
|
|
||||||
|
|||||||
206
backend/managers/profileManager.js
Normal file
206
backend/managers/profileManager.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const {
|
||||||
|
loadConfig,
|
||||||
|
saveConfig,
|
||||||
|
getModsPath
|
||||||
|
} = require('../core/config');
|
||||||
|
|
||||||
|
// Lazy-load modManager to avoid circular deps, or keep imports structured.
|
||||||
|
// For now, access mod paths directly or use helper helpers.
|
||||||
|
|
||||||
|
class ProfileManager {
|
||||||
|
constructor() {
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// Migration: specific check to see if we have profiles yet
|
||||||
|
if (!config.profiles || Object.keys(config.profiles).length === 0) {
|
||||||
|
this.migrateLegacyConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
console.log('[ProfileManager] Initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateLegacyConfig(config) {
|
||||||
|
console.log('[ProfileManager] Migrating legacy config to profile system...');
|
||||||
|
|
||||||
|
// Create a default profile with current settings
|
||||||
|
const defaultProfileId = 'default';
|
||||||
|
const defaultProfile = {
|
||||||
|
id: defaultProfileId,
|
||||||
|
name: 'Default',
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
lastUsed: new Date().toISOString(),
|
||||||
|
|
||||||
|
// settings specific to this profile
|
||||||
|
// If global settings existed, we copy them here
|
||||||
|
mods: config.installedMods || [], // Legacy mods are now part of default profile
|
||||||
|
javaPath: config.javaPath || '',
|
||||||
|
gameOptions: {
|
||||||
|
minMemory: '1G',
|
||||||
|
maxMemory: '4G',
|
||||||
|
args: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
profiles: {
|
||||||
|
[defaultProfileId]: defaultProfile
|
||||||
|
},
|
||||||
|
activeProfileId: defaultProfileId,
|
||||||
|
// Mods are currently treated as files on disk.
|
||||||
|
// The profile's `mods` array is the source of truth for enabled/known mods per profile.
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
saveConfig(updates);
|
||||||
|
console.log('[ProfileManager] Migration complete. Created Default profile.');
|
||||||
|
}
|
||||||
|
|
||||||
|
createProfile(name) {
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
throw new Error('Invalid profile name');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const id = uuidv4();
|
||||||
|
|
||||||
|
const newProfile = {
|
||||||
|
id,
|
||||||
|
name: name.trim(),
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
lastUsed: null,
|
||||||
|
mods: [], // Start with no mods enabled
|
||||||
|
javaPath: '',
|
||||||
|
gameOptions: {
|
||||||
|
minMemory: '1G',
|
||||||
|
maxMemory: '4G',
|
||||||
|
args: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const profiles = config.profiles || {};
|
||||||
|
profiles[id] = newProfile;
|
||||||
|
|
||||||
|
saveConfig({ profiles });
|
||||||
|
|
||||||
|
console.log(`[ProfileManager] Created new profile: "${name}" (${id})`);
|
||||||
|
return newProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProfiles() {
|
||||||
|
const config = loadConfig();
|
||||||
|
return Object.values(config.profiles || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
getProfile(id) {
|
||||||
|
const config = loadConfig();
|
||||||
|
return (config.profiles && config.profiles[id]) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveProfile() {
|
||||||
|
const config = loadConfig();
|
||||||
|
const activeId = config.activeProfileId;
|
||||||
|
if (!activeId || !config.profiles || !config.profiles[activeId]) {
|
||||||
|
// Fallback if something is corrupted
|
||||||
|
return this.getProfiles()[0] || null;
|
||||||
|
}
|
||||||
|
return config.profiles[activeId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateProfile(id) {
|
||||||
|
const config = loadConfig();
|
||||||
|
if (!config.profiles || !config.profiles[id]) {
|
||||||
|
throw new Error(`Profile not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.activeProfileId === id) {
|
||||||
|
console.log(`[ProfileManager] Profile ${id} is already active.`);
|
||||||
|
return config.profiles[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ProfileManager] Switching to profile: ${config.profiles[id].name} (${id})`);
|
||||||
|
|
||||||
|
// 1. Update config first
|
||||||
|
config.profiles[id].lastUsed = new Date().toISOString();
|
||||||
|
saveConfig({
|
||||||
|
activeProfileId: id,
|
||||||
|
profiles: config.profiles
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Trigger Mod Sync
|
||||||
|
// We need to require this here to ensure it uses the *newly saved* active profile ID
|
||||||
|
const { syncModsForCurrentProfile } = require('./modManager');
|
||||||
|
await syncModsForCurrentProfile();
|
||||||
|
|
||||||
|
return config.profiles[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteProfile(id) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const profiles = config.profiles || {};
|
||||||
|
|
||||||
|
if (!profiles[id]) {
|
||||||
|
throw new Error('Profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.activeProfileId === id) {
|
||||||
|
throw new Error('Cannot delete the active profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow deleting the last profile
|
||||||
|
if (Object.keys(profiles).length <= 1) {
|
||||||
|
throw new Error('Cannot delete the only remaining profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
delete profiles[id];
|
||||||
|
saveConfig({ profiles });
|
||||||
|
console.log(`[ProfileManager] Deleted profile: ${id}`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProfile(id, updates) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const profiles = config.profiles || {};
|
||||||
|
|
||||||
|
if (!profiles[id]) {
|
||||||
|
throw new Error('Profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety checks on updates
|
||||||
|
const allowedFields = ['name', 'javaPath', 'gameOptions', 'mods'];
|
||||||
|
const sanitizedUpdates = {};
|
||||||
|
|
||||||
|
Object.keys(updates).forEach(key => {
|
||||||
|
if (allowedFields.includes(key)) {
|
||||||
|
sanitizedUpdates[key] = updates[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
profiles[id] = { ...profiles[id], ...sanitizedUpdates };
|
||||||
|
|
||||||
|
saveConfig({ profiles });
|
||||||
|
console.log(`[ProfileManager] Updated profile: ${id}`);
|
||||||
|
|
||||||
|
// If we updated mods for the *active* profile, we might need to sync immediately
|
||||||
|
if (config.activeProfileId === id && updates.mods) {
|
||||||
|
// Optionally trigger sync?
|
||||||
|
// Sync is usually triggered when toggling a single mod.
|
||||||
|
// For bulk updates, the caller should decide when to sync.
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ProfileManager();
|
||||||
@@ -10,7 +10,7 @@ async function downloadAndReplaceHomePageUI(gameDir, progressCallback) {
|
|||||||
progressCallback('Downloading HomePage.ui...', null, null, null, null);
|
progressCallback('Downloading HomePage.ui...', null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const homeUIUrl = 'http://3.10.208.30:3002/api/HomeUI';
|
const homeUIUrl = 'https://files.hytalef2p.com/api/HomeUI';
|
||||||
const tempHomePath = path.join(path.dirname(gameDir), 'HomePage_temp.ui');
|
const tempHomePath = path.join(path.dirname(gameDir), 'HomePage_temp.ui');
|
||||||
|
|
||||||
await downloadFile(homeUIUrl, tempHomePath);
|
await downloadFile(homeUIUrl, tempHomePath);
|
||||||
@@ -63,7 +63,7 @@ async function downloadAndReplaceLogo(gameDir, progressCallback) {
|
|||||||
progressCallback('Downloading Logo@2x.png...', null, null, null, null);
|
progressCallback('Downloading Logo@2x.png...', null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const logoUrl = 'http://3.10.208.30:3002/api/Logo';
|
const logoUrl = 'https://files.hytalef2p.com/api/Logo';
|
||||||
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
|
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
|
||||||
|
|
||||||
await downloadFile(logoUrl, tempLogoPath);
|
await downloadFile(logoUrl, tempLogoPath);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
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 { checkExistingGameInstallation, updateGameFiles } = require('../managers/gameManager');
|
||||||
const { getInstalledClientVersion, getLatestClientVersion } = require('./versionManager');
|
const { getInstalledClientVersion, getLatestClientVersion } = require('./versionManager');
|
||||||
|
|
||||||
@@ -56,6 +56,14 @@ async function handleFirstLaunchCheck(progressCallback) {
|
|||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
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) {
|
if (config.hasLaunchedBefore === true) {
|
||||||
return { isFirstLaunch: false, needsUpdate: false };
|
return { isFirstLaunch: false, needsUpdate: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
|
||||||
async function getLatestClientVersion() {
|
async function getLatestClientVersion(branch = 'release') {
|
||||||
try {
|
try {
|
||||||
console.log('Fetching latest client version from API...');
|
console.log(`Fetching latest client version from API (branch: ${branch})...`);
|
||||||
const response = await axios.get('http://3.10.208.30:3002/api/version_client', {
|
const response = await axios.get('https://files.hytalef2p.com/api/version_client', {
|
||||||
|
params: { branch },
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Hytale-F2P-Launcher'
|
'User-Agent': 'Hytale-F2P-Launcher'
|
||||||
@@ -12,7 +13,7 @@ async function getLatestClientVersion() {
|
|||||||
|
|
||||||
if (response.data && response.data.client_version) {
|
if (response.data && response.data.client_version) {
|
||||||
const version = 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;
|
return version;
|
||||||
} else {
|
} else {
|
||||||
console.log('Warning: Invalid API response, falling back to default version');
|
console.log('Warning: Invalid API response, falling back to default version');
|
||||||
@@ -25,58 +26,6 @@ async function getLatestClientVersion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getInstalledClientVersion() {
|
|
||||||
try {
|
|
||||||
console.log('Fetching installed client version from API...');
|
|
||||||
const response = await axios.get('http://3.10.208.30:3002/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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMultiClientVersion() {
|
|
||||||
try {
|
|
||||||
console.log('Fetching Multiplayer version from API...');
|
|
||||||
const response = await axios.get('http://3.10.208.30:3002/api/multi', {
|
|
||||||
timeout: 5000,
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Hytale-F2P-Launcher'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data && response.data.multi_version) {
|
|
||||||
const version = response.data.multi_version;
|
|
||||||
console.log(`Multiplayer version: ${version}`);
|
|
||||||
return version;
|
|
||||||
} else {
|
|
||||||
console.log('Warning: Invalid multi API response');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching Multiplayer version:', error.message);
|
|
||||||
console.log('Multiplayer not available');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getLatestClientVersion,
|
getLatestClientVersion
|
||||||
getInstalledClientVersion,
|
|
||||||
getMultiClientVersion
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
const axios = require('axios');
|
|
||||||
|
|
||||||
const UPDATE_CHECK_URL = 'http://3.10.208.30:3002/api/version_launcher';
|
|
||||||
const CURRENT_VERSION = '2.0.1';
|
|
||||||
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;
|
|
||||||
634
backend/utils/clientPatcher.js
Normal file
634
backend/utils/clientPatcher.js
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
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) {
|
||||||
|
return process.env.HYTALE_AUTH_DOMAIN;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { getAuthDomain } = require('../core/config');
|
||||||
|
return getAuthDomain();
|
||||||
|
} catch (e) {
|
||||||
|
return 'auth.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() {
|
||||||
|
this.patchedFlag = '.patched_custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the target domain for patching
|
||||||
|
*/
|
||||||
|
getNewDomain() {
|
||||||
|
const domain = getTargetDomain();
|
||||||
|
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) {
|
||||||
|
return {
|
||||||
|
mode: 'direct',
|
||||||
|
mainDomain: domain,
|
||||||
|
subdomainPrefix: '',
|
||||||
|
description: `Direct replacement: hytale.com -> ${domain}`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
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]
|
||||||
|
*/
|
||||||
|
stringToLengthPrefixed(str) {
|
||||||
|
const length = str.length;
|
||||||
|
const result = Buffer.alloc(4 + length + (length - 1));
|
||||||
|
|
||||||
|
result[0] = length;
|
||||||
|
result[1] = 0x00;
|
||||||
|
result[2] = 0x00;
|
||||||
|
result[3] = 0x00;
|
||||||
|
|
||||||
|
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)
|
||||||
|
*/
|
||||||
|
stringToUtf16LE(str) {
|
||||||
|
const buf = Buffer.alloc(str.length * 2);
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
buf.writeUInt16LE(str.charCodeAt(i), i * 2);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a string to UTF-8 bytes (how Java stores strings)
|
||||||
|
*/
|
||||||
|
stringToUtf8(str) {
|
||||||
|
return Buffer.from(str, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all occurrences of a pattern in a buffer
|
||||||
|
*/
|
||||||
|
findAllOccurrences(buffer, pattern) {
|
||||||
|
const positions = [];
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < buffer.length) {
|
||||||
|
const index = buffer.indexOf(pattern, pos);
|
||||||
|
if (index === -1) break;
|
||||||
|
positions.push(index);
|
||||||
|
pos = index + 1;
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace bytes in buffer - only overwrites the length of new bytes
|
||||||
|
* Does NOT null-pad to avoid corrupting adjacent data
|
||||||
|
*/
|
||||||
|
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 - don't null-fill!
|
||||||
|
newBytes.copy(result, pos);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buffer: result, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTF-8 domain replacement for Java JAR files
|
||||||
|
*/
|
||||||
|
findAndReplaceDomainUtf8(data, oldDomain, newDomain) {
|
||||||
|
let count = 0;
|
||||||
|
const result = Buffer.from(data);
|
||||||
|
|
||||||
|
const oldUtf8 = this.stringToUtf8(oldDomain);
|
||||||
|
const newUtf8 = this.stringToUtf8(newDomain);
|
||||||
|
|
||||||
|
const positions = this.findAllOccurrences(result, oldUtf8);
|
||||||
|
|
||||||
|
for (const pos of positions) {
|
||||||
|
newUtf8.copy(result, pos);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buffer: result, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart domain replacement for .NET AOT binaries
|
||||||
|
*/
|
||||||
|
findAndReplaceDomainSmart(data, oldDomain, newDomain) {
|
||||||
|
let count = 0;
|
||||||
|
const result = Buffer.from(data);
|
||||||
|
|
||||||
|
if (newDomain.length > oldDomain.length) {
|
||||||
|
console.warn(` Warning: New domain longer than old, skipping smart replacement`);
|
||||||
|
return { buffer: result, count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1));
|
||||||
|
const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1));
|
||||||
|
|
||||||
|
const oldLastCharByte = oldDomain.charCodeAt(oldDomain.length - 1);
|
||||||
|
const newLastCharByte = newDomain.charCodeAt(newDomain.length - 1);
|
||||||
|
|
||||||
|
const positions = this.findAllOccurrences(result, oldUtf16NoLast);
|
||||||
|
|
||||||
|
for (const pos of positions) {
|
||||||
|
const lastCharPos = pos + oldUtf16NoLast.length;
|
||||||
|
if (lastCharPos + 1 > result.length) continue;
|
||||||
|
|
||||||
|
const lastCharFirstByte = result[lastCharPos];
|
||||||
|
|
||||||
|
if (lastCharFirstByte === oldLastCharByte) {
|
||||||
|
// Only overwrite, don't null-fill
|
||||||
|
newUtf16NoLast.copy(result, pos);
|
||||||
|
result[lastCharPos] = newLastCharByte;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buffer: result, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all domain patches using length-prefixed format
|
||||||
|
*/
|
||||||
|
applyDomainPatches(data, domain, protocol = 'https://') {
|
||||||
|
let result = Buffer.from(data);
|
||||||
|
let totalCount = 0;
|
||||||
|
const strategy = this.getDomainStrategy(domain);
|
||||||
|
|
||||||
|
console.log(` Patching strategy: ${strategy.description}`);
|
||||||
|
|
||||||
|
// ULTRA-MINIMAL PATCHING - only domain, no subdomain patches
|
||||||
|
console.log(` Ultra-minimal mode: only patching main domain`);
|
||||||
|
|
||||||
|
// Only patch main domain (hytale.com -> mainDomain)
|
||||||
|
const domainResult = this.replaceBytes(
|
||||||
|
result,
|
||||||
|
this.stringToLengthPrefixed(ORIGINAL_DOMAIN),
|
||||||
|
this.stringToLengthPrefixed(strategy.mainDomain)
|
||||||
|
);
|
||||||
|
result = domainResult.buffer;
|
||||||
|
if (domainResult.count > 0) {
|
||||||
|
console.log(` Patched ${domainResult.count} domain occurrence(s)`);
|
||||||
|
totalCount += domainResult.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip ALL subdomain patches - let them stay as sessions.hytale.com etc
|
||||||
|
console.log(` Skipping all subdomain patches (ultra-minimal mode)`);
|
||||||
|
|
||||||
|
return { buffer: result, count: totalCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the client binary has already been patched
|
||||||
|
*/
|
||||||
|
isPatchedAlready(clientPath) {
|
||||||
|
const newDomain = this.getNewDomain();
|
||||||
|
const patchFlagFile = clientPath + this.patchedFlag;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the client as patched
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
patchMode: strategy.mode,
|
||||||
|
mainDomain: strategy.mainDomain,
|
||||||
|
subdomainPrefix: strategy.subdomainPrefix,
|
||||||
|
patcherVersion: '2.1.0'
|
||||||
|
};
|
||||||
|
fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a backup of the original client binary
|
||||||
|
*/
|
||||||
|
backupClient(clientPath) {
|
||||||
|
const backupPath = clientPath + '.original';
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
console.log(` Creating backup at ${path.basename(backupPath)}`);
|
||||||
|
fs.copyFileSync(clientPath, backupPath);
|
||||||
|
return backupPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSize = fs.statSync(clientPath).size;
|
||||||
|
const backupSize = fs.statSync(backupPath).size;
|
||||||
|
|
||||||
|
if (currentSize !== backupSize) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore the original client binary from backup
|
||||||
|
*/
|
||||||
|
restoreClient(clientPath) {
|
||||||
|
const backupPath = clientPath + '.original';
|
||||||
|
if (fs.existsSync(backupPath)) {
|
||||||
|
fs.copyFileSync(backupPath, clientPath);
|
||||||
|
const patchFlagFile = clientPath + this.patchedFlag;
|
||||||
|
if (fs.existsSync(patchFlagFile)) {
|
||||||
|
fs.unlinkSync(patchFlagFile);
|
||||||
|
}
|
||||||
|
console.log('Client restored from backup');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
console.log('No backup found to restore');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch the client binary to use the custom domain
|
||||||
|
*/
|
||||||
|
async patchClient(clientPath, progressCallback) {
|
||||||
|
const newDomain = this.getNewDomain();
|
||||||
|
const strategy = this.getDomainStrategy(newDomain);
|
||||||
|
|
||||||
|
console.log('=== Client Patcher v2.1 ===');
|
||||||
|
console.log(`Target: ${clientPath}`);
|
||||||
|
console.log(`Domain: ${newDomain} (${newDomain.length} chars)`);
|
||||||
|
console.log(`Mode: ${strategy.mode}`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(clientPath)) {
|
||||||
|
const error = `Client binary not found: ${clientPath}`;
|
||||||
|
console.error(error);
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPatchedAlready(clientPath)) {
|
||||||
|
console.log(`Client already patched for ${newDomain}, skipping`);
|
||||||
|
if (progressCallback) progressCallback('Client already patched', 100);
|
||||||
|
return { success: true, alreadyPatched: true, patchCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) progressCallback('Preparing to patch client...', 10);
|
||||||
|
|
||||||
|
console.log('Creating backup...');
|
||||||
|
this.backupClient(clientPath);
|
||||||
|
|
||||||
|
if (progressCallback) progressCallback('Reading client binary...', 20);
|
||||||
|
|
||||||
|
console.log('Reading client binary...');
|
||||||
|
const data = fs.readFileSync(clientPath);
|
||||||
|
console.log(`Binary size: ${(data.length / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
|
||||||
|
if (progressCallback) progressCallback('Patching domain references...', 50);
|
||||||
|
|
||||||
|
console.log('Applying domain patches...');
|
||||||
|
const { buffer: patchedData, count } = this.applyDomainPatches(data, newDomain);
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
// Try legacy UTF-16LE format
|
||||||
|
console.log('No occurrences found - trying legacy UTF-16LE format...');
|
||||||
|
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');
|
||||||
|
return { success: true, patchCount: 0, warning: 'No occurrences found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) progressCallback('Writing patched binary...', 80);
|
||||||
|
|
||||||
|
console.log('Writing patched binary...');
|
||||||
|
fs.writeFileSync(clientPath, patchedData);
|
||||||
|
this.markAsPatched(clientPath);
|
||||||
|
|
||||||
|
if (progressCallback) progressCallback('Patching complete', 100);
|
||||||
|
|
||||||
|
console.log(`Successfully patched ${count} occurrences`);
|
||||||
|
console.log('=== Patching Complete ===');
|
||||||
|
|
||||||
|
return { success: true, patchCount: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch the server JAR by downloading pre-patched version
|
||||||
|
*/
|
||||||
|
async patchServer(serverPath, progressCallback, javaPath = null) {
|
||||||
|
const newDomain = this.getNewDomain();
|
||||||
|
|
||||||
|
console.log('=== Server Patcher ===');
|
||||||
|
console.log(`Target: ${serverPath}`);
|
||||||
|
console.log(`Domain: ${newDomain}`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(serverPath)) {
|
||||||
|
const error = `Server JAR not found: ${serverPath}`;
|
||||||
|
console.error(error);
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return { success: true, alreadyPatched: true };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Re-patch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) progressCallback('Creating backup...', 10);
|
||||||
|
console.log('Creating backup...');
|
||||||
|
this.backupClient(serverPath);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const handleResponse = (response) => {
|
||||||
|
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||||
|
https.get(response.headers.location, handleResponse).on('error', reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`HTTP ${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
https.get(url, handleResponse).on('error', (err) => {
|
||||||
|
fs.unlink(serverPath, () => {});
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(' Download successful');
|
||||||
|
|
||||||
|
fs.writeFileSync(patchFlagFile, JSON.stringify({
|
||||||
|
domain: newDomain,
|
||||||
|
patchedAt: new Date().toISOString(),
|
||||||
|
patcher: 'PrePatchedDownload',
|
||||||
|
source: url
|
||||||
|
}));
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
findJava() {
|
||||||
|
try {
|
||||||
|
const bundled = getBundledJavaPath(JRE_DIR);
|
||||||
|
if (bundled && fs.existsSync(bundled)) {
|
||||||
|
return bundled;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const javaExec = getJavaExec(JRE_DIR);
|
||||||
|
if (javaExec && fs.existsSync(javaExec)) {
|
||||||
|
return javaExec;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
if (process.env.JAVA_HOME) {
|
||||||
|
const javaBin = path.join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java');
|
||||||
|
if (fs.existsSync(javaBin)) {
|
||||||
|
return javaBin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync('java -version 2>&1', { encoding: 'utf8' });
|
||||||
|
return 'java';
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the client binary path based on platform
|
||||||
|
*/
|
||||||
|
findClientPath(gameDir) {
|
||||||
|
const candidates = [];
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
candidates.push(path.join(gameDir, 'Client', 'Hytale.app', 'Contents', 'MacOS', 'HytaleClient'));
|
||||||
|
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
candidates.push(path.join(gameDir, 'Client', 'HytaleClient.exe'));
|
||||||
|
} else {
|
||||||
|
candidates.push(path.join(gameDir, 'Client', 'HytaleClient'));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
findServerPath(gameDir) {
|
||||||
|
const candidates = [
|
||||||
|
path.join(gameDir, 'Server', 'HytaleServer.jar'),
|
||||||
|
path.join(gameDir, 'Server', 'server.jar')
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure both client and server are patched before launching
|
||||||
|
*/
|
||||||
|
async ensureClientPatched(gameDir, progressCallback, javaPath = null) {
|
||||||
|
const results = {
|
||||||
|
client: null,
|
||||||
|
server: null,
|
||||||
|
success: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientPath = this.findClientPath(gameDir);
|
||||||
|
if (clientPath) {
|
||||||
|
if (progressCallback) progressCallback('Patching client binary...', 10);
|
||||||
|
results.client = await this.patchClient(clientPath, (msg, pct) => {
|
||||||
|
if (progressCallback) progressCallback(`Client: ${msg}`, pct ? pct / 2 : null);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('Could not find HytaleClient binary');
|
||||||
|
results.client = { success: false, error: 'Client binary not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPath = this.findServerPath(gameDir);
|
||||||
|
if (serverPath) {
|
||||||
|
if (progressCallback) progressCallback('Patching server JAR...', 50);
|
||||||
|
results.server = await this.patchServer(serverPath, (msg, pct) => {
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
|
||||||
|
results.success = (results.client && results.client.success) || (results.server && results.server.success);
|
||||||
|
results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.server && results.server.alreadyPatched);
|
||||||
|
results.patchCount = (results.client ? results.client.patchCount || 0 : 0) + (results.server ? results.server.patchCount || 0 : 0);
|
||||||
|
|
||||||
|
if (progressCallback) progressCallback('Patching complete', 100);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ClientPatcher();
|
||||||
@@ -2,49 +2,461 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
|
||||||
async function downloadFile(url, dest, progressCallback) {
|
// Automatic stall retry constants
|
||||||
const response = await axios({
|
const MAX_AUTOMATIC_STALL_RETRIES = 3;
|
||||||
method: 'GET',
|
const AUTOMATIC_STALL_RETRY_DELAY = 3000; // 3 seconds in milliseconds
|
||||||
url: url,
|
|
||||||
responseType: 'stream',
|
// Network monitoring utilities using Node.js built-in methods
|
||||||
headers: {
|
function checkNetworkConnection() {
|
||||||
'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',
|
return new Promise((resolve) => {
|
||||||
'Accept': '*/*',
|
const { lookup } = require('dns');
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
const http = require('http');
|
||||||
'Referer': 'https://launcher.hytale.com/'
|
|
||||||
|
// 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) {
|
||||||
|
// 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: 120000, // 120 seconds for slow connections
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: headers,
|
||||||
|
validateStatus: function (status) {
|
||||||
|
return (status >= 200 && status < 300) || status === 206;
|
||||||
|
},
|
||||||
|
maxRedirects: 5,
|
||||||
|
family: 4
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentLength = response.headers['content-length'];
|
||||||
|
const totalSize = contentLength ? parseInt(contentLength, 10) + startByte : 0;
|
||||||
|
let downloaded = startByte;
|
||||||
|
lastProgressTime = Date.now();
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// 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, {
|
||||||
|
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 simple stall timer on data received
|
||||||
|
if (stalledTimeout) {
|
||||||
|
clearTimeout(stalledTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 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);
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
// 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) => {
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const totalSize = parseInt(response.headers['content-length'], 10);
|
// Enhanced file cleanup with validation
|
||||||
let downloaded = 0;
|
if (fs.existsSync(dest)) {
|
||||||
const startTime = Date.now();
|
try {
|
||||||
|
// HTTP 416 = Range Not Satisfiable, delete corrupted partial file
|
||||||
const writer = fs.createWriteStream(dest);
|
const isRangeError = error.message && error.message.includes('416');
|
||||||
|
|
||||||
response.data.on('data', (chunk) => {
|
// Check if file is corrupted (small or invalid) or if error is non-resumable
|
||||||
downloaded += chunk.length;
|
const partialStats = fs.statSync(dest);
|
||||||
if (progressCallback && totalSize > 0) {
|
const isResumableError = error.message && (
|
||||||
const percent = Math.min(100, Math.max(0, (downloaded / totalSize) * 100));
|
error.message.includes('stalled') ||
|
||||||
const elapsed = (Date.now() - startTime) / 1000;
|
error.message.includes('timeout') ||
|
||||||
const speed = elapsed > 0 ? downloaded / elapsed : 0;
|
error.message.includes('network') ||
|
||||||
progressCallback(null, percent, speed, downloaded, totalSize);
|
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 handle partial file:', cleanupError.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
response.data.pipe(writer);
|
// 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);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
// Respect error's canRetry property if set
|
||||||
writer.on('finish', resolve);
|
const canRetry = (error.canRetry === false) ? false : isRetryable;
|
||||||
writer.on('error', reject);
|
|
||||||
response.data.on('error', reject);
|
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...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function findHomePageUIPath(gameLatest) {
|
||||||
function searchDirectory(dir) {
|
function searchDirectory(dir) {
|
||||||
try {
|
try {
|
||||||
const items = fs.readdirSync(dir, { withFileTypes: true });
|
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.isFile() && item.name === 'HomePage.ui') {
|
if (item.isFile() && item.name === 'HomePage.ui') {
|
||||||
return path.join(dir, item.name);
|
return path.join(dir, item.name);
|
||||||
@@ -57,14 +469,14 @@ function findHomePageUIPath(gameLatest) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(gameLatest)) {
|
if (!fs.existsSync(gameLatest)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return searchDirectory(gameLatest);
|
return searchDirectory(gameLatest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +484,7 @@ function findLogoPath(gameLatest) {
|
|||||||
function searchDirectory(dir) {
|
function searchDirectory(dir) {
|
||||||
try {
|
try {
|
||||||
const items = fs.readdirSync(dir, { withFileTypes: true });
|
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.isFile() && item.name === 'Logo@2x.png') {
|
if (item.isFile() && item.name === 'Logo@2x.png') {
|
||||||
return path.join(dir, item.name);
|
return path.join(dir, item.name);
|
||||||
@@ -85,19 +497,93 @@ function findLogoPath(gameLatest) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(gameLatest)) {
|
if (!fs.existsSync(gameLatest)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return searchDirectory(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 = {
|
module.exports = {
|
||||||
downloadFile,
|
downloadFile,
|
||||||
|
retryDownload,
|
||||||
|
retryStalledDownload,
|
||||||
findHomePageUIPath,
|
findHomePageUIPath,
|
||||||
findLogoPath
|
findLogoPath
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
function getOS() {
|
function getOS() {
|
||||||
if (process.platform === 'win32') return 'windows';
|
if (process.platform === 'win32') return 'windows';
|
||||||
@@ -52,11 +53,7 @@ function setupWaylandEnvironment() {
|
|||||||
console.log('Detected Wayland session, configuring environment...');
|
console.log('Detected Wayland session, configuring environment...');
|
||||||
|
|
||||||
const envVars = {
|
const envVars = {
|
||||||
SDL_VIDEODRIVER: 'wayland',
|
SDL_VIDEODRIVER: 'wayland'
|
||||||
GDK_BACKEND: 'wayland',
|
|
||||||
QT_QPA_PLATFORM: 'wayland',
|
|
||||||
MOZ_ENABLE_WAYLAND: '1',
|
|
||||||
_JAVA_AWT_WM_NONREPARENTING: '1'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
envVars.ELECTRON_OZONE_PLATFORM_HINT = 'wayland';
|
envVars.ELECTRON_OZONE_PLATFORM_HINT = 'wayland';
|
||||||
@@ -65,9 +62,182 @@ function setupWaylandEnvironment() {
|
|||||||
return envVars;
|
return envVars;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectGpu() {
|
||||||
|
const platform = getOS();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (platform === 'linux') {
|
||||||
|
return detectGpuLinux();
|
||||||
|
} else if (platform === 'windows') {
|
||||||
|
return detectGpuWindows();
|
||||||
|
} else if (platform === 'darwin') {
|
||||||
|
return detectGpuMac();
|
||||||
|
} else {
|
||||||
|
return { mode: 'integrated', vendor: 'intel', integratedName: 'Unknown', dedicatedName: null };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('GPU detection failed, falling back to integrated:', error.message);
|
||||||
|
return { mode: 'integrated', vendor: 'intel', integratedName: 'Unknown', dedicatedName: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectGpuLinux() {
|
||||||
|
const output = execSync('lspci -nn | grep \'VGA\\|3D\'', { encoding: 'utf8' });
|
||||||
|
const lines = output.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
let integratedName = null;
|
||||||
|
let dedicatedName = null;
|
||||||
|
let hasNvidia = false;
|
||||||
|
let hasAmd = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.includes('VGA') || line.includes('3D')) {
|
||||||
|
const match = line.match(/\[([^\]]+)\]/g);
|
||||||
|
let modelName = null;
|
||||||
|
if (match && match.length >= 2) {
|
||||||
|
modelName = match[1].slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.includes('10de:') || line.toLowerCase().includes('nvidia')) {
|
||||||
|
hasNvidia = true;
|
||||||
|
dedicatedName = "NVIDIA " + modelName || 'NVIDIA GPU';
|
||||||
|
console.log('Detected NVIDIA GPU:', dedicatedName);
|
||||||
|
} else if (line.includes('1002:') || line.toLowerCase().includes('amd') || line.toLowerCase().includes('radeon')) {
|
||||||
|
hasAmd = true;
|
||||||
|
dedicatedName = "AMD " + modelName || 'AMD GPU';
|
||||||
|
console.log('Detected AMD GPU:', dedicatedName);
|
||||||
|
} else if (line.includes('8086:') || line.toLowerCase().includes('intel')) {
|
||||||
|
integratedName = "Intel " + modelName || 'Intel GPU';
|
||||||
|
console.log('Detected Intel GPU:', integratedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNvidia) {
|
||||||
|
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||||
|
} else if (hasAmd) {
|
||||||
|
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||||
|
} else {
|
||||||
|
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Intel GPU', dedicatedName: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectGpuWindows() {
|
||||||
|
const output = execSync('wmic path win32_VideoController get name', { encoding: 'utf8' });
|
||||||
|
const lines = output.split('\n').map(line => line.trim()).filter(line => line && line !== 'Name');
|
||||||
|
|
||||||
|
let integratedName = null;
|
||||||
|
let dedicatedName = null;
|
||||||
|
let hasNvidia = false;
|
||||||
|
let hasAmd = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const lowerLine = line.toLowerCase();
|
||||||
|
if (lowerLine.includes('nvidia')) {
|
||||||
|
hasNvidia = true;
|
||||||
|
dedicatedName = line;
|
||||||
|
console.log('Detected NVIDIA GPU:', dedicatedName);
|
||||||
|
} else if (lowerLine.includes('amd') || lowerLine.includes('radeon')) {
|
||||||
|
hasAmd = true;
|
||||||
|
dedicatedName = line;
|
||||||
|
console.log('Detected AMD GPU:', dedicatedName);
|
||||||
|
} else if (lowerLine.includes('intel')) {
|
||||||
|
integratedName = line;
|
||||||
|
console.log('Detected Intel GPU:', integratedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNvidia) {
|
||||||
|
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||||
|
} else if (hasAmd) {
|
||||||
|
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||||
|
} else {
|
||||||
|
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Intel GPU', dedicatedName: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectGpuMac() {
|
||||||
|
const output = execSync('system_profiler SPDisplaysDataType', { encoding: 'utf8' });
|
||||||
|
const lines = output.split('\n');
|
||||||
|
|
||||||
|
let integratedName = null;
|
||||||
|
let dedicatedName = null;
|
||||||
|
let hasNvidia = false;
|
||||||
|
let hasAmd = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.includes('Chipset Model:')) {
|
||||||
|
const gpuName = line.split('Chipset Model:')[1].trim();
|
||||||
|
const lowerGpu = gpuName.toLowerCase();
|
||||||
|
if (lowerGpu.includes('nvidia')) {
|
||||||
|
hasNvidia = true;
|
||||||
|
dedicatedName = gpuName;
|
||||||
|
console.log('Detected NVIDIA GPU:', dedicatedName);
|
||||||
|
} else if (lowerGpu.includes('amd') || lowerGpu.includes('radeon')) {
|
||||||
|
hasAmd = true;
|
||||||
|
dedicatedName = gpuName;
|
||||||
|
console.log('Detected AMD GPU:', dedicatedName);
|
||||||
|
} else if (lowerGpu.includes('intel') || lowerGpu.includes('iris') || lowerGpu.includes('uhd')) {
|
||||||
|
integratedName = gpuName;
|
||||||
|
console.log('Detected Intel GPU:', integratedName);
|
||||||
|
} else if (!dedicatedName && !integratedName) {
|
||||||
|
// Fallback for Apple Silicon or other
|
||||||
|
integratedName = gpuName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNvidia) {
|
||||||
|
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Integrated GPU', dedicatedName };
|
||||||
|
} else if (hasAmd) {
|
||||||
|
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Integrated GPU', dedicatedName };
|
||||||
|
} else {
|
||||||
|
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Integrated GPU', dedicatedName: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGpuEnvironment(gpuPreference) {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalPreference = gpuPreference;
|
||||||
|
let detected = detectGpu();
|
||||||
|
|
||||||
|
if (gpuPreference === 'auto') {
|
||||||
|
finalPreference = detected.mode;
|
||||||
|
console.log(`Auto-detected GPU: ${detected.vendor} (${detected.mode})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Preferred GPU set to:', finalPreference);
|
||||||
|
|
||||||
|
const envVars = {};
|
||||||
|
|
||||||
|
if (finalPreference === 'dedicated') {
|
||||||
|
if (detected.vendor === 'nvidia') {
|
||||||
|
envVars.__NV_PRIME_RENDER_OFFLOAD = '1';
|
||||||
|
envVars.__GLX_VENDOR_LIBRARY_NAME = 'nvidia';
|
||||||
|
const nvidiaEglFile = '/usr/share/glvnd/egl_vendor.d/10_nvidia.json';
|
||||||
|
if (fs.existsSync(nvidiaEglFile)) {
|
||||||
|
envVars.__EGL_VENDOR_LIBRARY_FILENAMES = nvidiaEglFile;
|
||||||
|
} else {
|
||||||
|
console.warn('NVIDIA EGL vendor library file not found, not setting __EGL_VENDOR_LIBRARY_FILENAMES');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
envVars.DRI_PRIME = '1';
|
||||||
|
}
|
||||||
|
console.log('GPU environment variables:', envVars);
|
||||||
|
} else {
|
||||||
|
console.log('Using integrated GPU, no environment variables set');
|
||||||
|
}
|
||||||
|
return envVars;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getOS,
|
getOS,
|
||||||
getArch,
|
getArch,
|
||||||
isWaylandSession,
|
isWaylandSession,
|
||||||
setupWaylandEnvironment
|
setupWaylandEnvironment,
|
||||||
|
detectGpu,
|
||||||
|
setupGpuEnvironment
|
||||||
};
|
};
|
||||||
|
|||||||
131
backend/utils/userDataBackup.js
Normal file
131
backend/utils/userDataBackup.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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,
|
||||||
|
dereference: true // Follow symlinks to avoid EPERM errors on Windows
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
dereference: true // Follow symlinks to avoid EPERM errors on Windows
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
172
backend/utils/userDataMigration.js
Normal file
172
backend/utils/userDataMigration.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
const fs = require('fs-extra');
|
||||||
|
const path = require('path');
|
||||||
|
const { getHytaleSavesDir, getResolvedAppDir } = require('../core/paths');
|
||||||
|
const { loadConfig, saveConfig } = require('../core/config');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NEW SYSTEM (2.1.2+): UserData Migration to Centralized Location
|
||||||
|
*
|
||||||
|
* UserData is now stored in a centralized location instead of inside game installation:
|
||||||
|
* - Windows: %LOCALAPPDATA%\HytaleSaves\
|
||||||
|
* - macOS: ~/Library/Application Support/HytaleSaves/
|
||||||
|
* - Linux: ~/.hytalesaves/
|
||||||
|
*
|
||||||
|
* This eliminates the need for backup/restore during updates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if migration to centralized UserData has been completed
|
||||||
|
*/
|
||||||
|
function isMigrationCompleted() {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.userDataMigrated === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark migration as completed
|
||||||
|
*/
|
||||||
|
function markMigrationCompleted() {
|
||||||
|
saveConfig({ userDataMigrated: true });
|
||||||
|
console.log('[UserDataMigration] Migration marked as completed in config');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find old UserData location (pre-2.1.2)
|
||||||
|
* Searches in: installPath/branch/package/game/latest/Client/UserData
|
||||||
|
*/
|
||||||
|
function findOldUserDataPath() {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const installPath = getResolvedAppDir();
|
||||||
|
const branch = config.version_branch || 'release';
|
||||||
|
|
||||||
|
console.log(`[UserDataMigration] Looking for old UserData...`);
|
||||||
|
console.log(`[UserDataMigration] Install path: ${installPath}`);
|
||||||
|
console.log(`[UserDataMigration] Branch: ${branch}`);
|
||||||
|
|
||||||
|
// Old location
|
||||||
|
const oldPath = path.join(installPath, branch, 'package', 'game', 'latest', 'Client', 'UserData');
|
||||||
|
console.log(`[UserDataMigration] Checking: ${oldPath}`);
|
||||||
|
console.log(`[UserDataMigration] Checking: ${oldPath}`);
|
||||||
|
|
||||||
|
if (fs.existsSync(oldPath)) {
|
||||||
|
console.log(`[UserDataMigration] ✓ Found old UserData at: ${oldPath}`);
|
||||||
|
return oldPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[UserDataMigration] ✗ Not found at current branch location`);
|
||||||
|
|
||||||
|
// Try other branch if current doesn't exist
|
||||||
|
const otherBranch = branch === 'release' ? 'pre-release' : 'release';
|
||||||
|
const otherPath = path.join(installPath, otherBranch, 'package', 'game', 'latest', 'Client', 'UserData');
|
||||||
|
console.log(`[UserDataMigration] Checking other branch: ${otherPath}`);
|
||||||
|
console.log(`[UserDataMigration] Checking other branch: ${otherPath}`);
|
||||||
|
|
||||||
|
if (fs.existsSync(otherPath)) {
|
||||||
|
console.log(`[UserDataMigration] ✓ Found old UserData in other branch at: ${otherPath}`);
|
||||||
|
return otherPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[UserDataMigration] ✗ No old UserData found in any branch');
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UserDataMigration] Error finding old UserData:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate UserData from old location to new centralized location
|
||||||
|
* One-time operation when upgrading to 2.1.2
|
||||||
|
*/
|
||||||
|
async function migrateUserDataToCentralized() {
|
||||||
|
// Check if already migrated
|
||||||
|
if (isMigrationCompleted()) {
|
||||||
|
console.log('[UserDataMigration] Migration already completed, skipping');
|
||||||
|
return { success: true, alreadyMigrated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[UserDataMigration] === Starting UserData Migration to Centralized Location ===');
|
||||||
|
|
||||||
|
const newUserDataPath = getHytaleSavesDir();
|
||||||
|
console.log(`[UserDataMigration] Target location: ${newUserDataPath}`);
|
||||||
|
|
||||||
|
// Ensure new directory exists
|
||||||
|
if (!fs.existsSync(newUserDataPath)) {
|
||||||
|
fs.mkdirSync(newUserDataPath, { recursive: true });
|
||||||
|
console.log('[UserDataMigration] Created new HytaleSaves directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find old UserData
|
||||||
|
const oldUserDataPath = findOldUserDataPath();
|
||||||
|
|
||||||
|
if (!oldUserDataPath) {
|
||||||
|
console.log('[UserDataMigration] No old UserData found - fresh install or already migrated');
|
||||||
|
// Don't mark as migrated - let it check again next time in case game gets installed later
|
||||||
|
return { success: true, freshInstall: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if new location already has data (shouldn't happen, but safety check)
|
||||||
|
const existingFiles = fs.readdirSync(newUserDataPath);
|
||||||
|
if (existingFiles.length > 0) {
|
||||||
|
console.warn('[UserDataMigration] New location already contains files, marking as migrated to avoid re-attempts');
|
||||||
|
markMigrationCompleted();
|
||||||
|
return { success: true, skipped: true, reason: 'target_not_empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[UserDataMigration] Copying from ${oldUserDataPath} to ${newUserDataPath}...`);
|
||||||
|
|
||||||
|
// Copy all UserData to new location
|
||||||
|
await fs.copy(oldUserDataPath, newUserDataPath, {
|
||||||
|
overwrite: false,
|
||||||
|
errorOnExist: false,
|
||||||
|
dereference: true // Follow symlinks to avoid EPERM errors on Windows
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[UserDataMigration] ✓ UserData copied successfully');
|
||||||
|
|
||||||
|
// Mark migration as completed
|
||||||
|
markMigrationCompleted();
|
||||||
|
|
||||||
|
console.log('[UserDataMigration] === Migration Completed Successfully ===');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
migrated: true,
|
||||||
|
from: oldUserDataPath,
|
||||||
|
to: newUserDataPath
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UserDataMigration] ✗ Migration failed:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
from: oldUserDataPath,
|
||||||
|
to: newUserDataPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the centralized UserData path (always use this in 2.1.2+)
|
||||||
|
* Ensures directory exists
|
||||||
|
*/
|
||||||
|
function getUserDataPath() {
|
||||||
|
const userDataPath = getHytaleSavesDir();
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (!fs.existsSync(userDataPath)) {
|
||||||
|
fs.mkdirSync(userDataPath, { recursive: true });
|
||||||
|
console.log(`[UserDataMigration] Created UserData directory: ${userDataPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return userDataPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
migrateUserDataToCentralized,
|
||||||
|
getUserDataPath,
|
||||||
|
isMigrationCompleted,
|
||||||
|
findOldUserDataPath
|
||||||
|
};
|
||||||
BIN
build/icon.icns
Normal file
BIN
build/icon.icns
Normal file
Binary file not shown.
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
3
dev-app-update.yml
Normal file
3
dev-app-update.yml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
provider: github
|
||||||
|
owner: amiayweb # Change to your own GitHub username
|
||||||
|
repo: Hytale-F2P
|
||||||
284
docs/AUTO-UPDATES.md
Normal file
284
docs/AUTO-UPDATES.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Auto-Updates System
|
||||||
|
|
||||||
|
This document explains how the automatic update system works in the Hytale F2P Launcher.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The launcher uses [electron-updater](https://www.electron.build/auto-update) to automatically check for, download, and install updates. When a new version is available, users are notified and the update is downloaded in the background.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### 1. Update Checking
|
||||||
|
|
||||||
|
- **Automatic Check**: The app automatically checks for updates 3 seconds after startup
|
||||||
|
- **Manual Check**: Users can manually check for updates through the UI
|
||||||
|
- **Update Source**: Updates are fetched from GitHub Releases
|
||||||
|
|
||||||
|
### 2. Update Process
|
||||||
|
|
||||||
|
1. **Check for Updates**: The app queries GitHub Releases for a newer version
|
||||||
|
2. **Notify User**: If an update is available, the user is notified via the UI
|
||||||
|
3. **Download**: The update is automatically downloaded in the background
|
||||||
|
4. **Progress Tracking**: Download progress is shown to the user
|
||||||
|
5. **Install**: When the download completes, the user can choose to install immediately or wait until the app restarts
|
||||||
|
|
||||||
|
### 3. Installation
|
||||||
|
|
||||||
|
- Updates are installed when the app quits (if `autoInstallOnAppQuit` is enabled)
|
||||||
|
- Users can also manually trigger installation through the UI
|
||||||
|
- The app will restart automatically after installation
|
||||||
|
|
||||||
|
## Version Detection & Comparison
|
||||||
|
|
||||||
|
### Current Version Source
|
||||||
|
|
||||||
|
The app's current version is read from `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "2.0.2b"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This version is embedded into the built application and is accessible via `app.getVersion()` in Electron. When the app is built, electron-builder also creates an internal `app-update.yml` file in the app's resources that contains this version information.
|
||||||
|
|
||||||
|
### How Version Detection Works
|
||||||
|
|
||||||
|
1. **Current Version**: The app knows its own version from `package.json`, which is:
|
||||||
|
- Read at build time
|
||||||
|
- Embedded in the application binary
|
||||||
|
- Stored in the app's metadata
|
||||||
|
|
||||||
|
2. **Fetching Latest Version**: When checking for updates, electron-updater:
|
||||||
|
- Queries the GitHub Releases API: `https://api.github.com/repos/amiayweb/Hytale-F2P/releases/latest`
|
||||||
|
- Or reads the update metadata file: `https://github.com/amiayweb/Hytale-F2P/releases/download/latest/latest.yml` (or `latest-mac.yml` for macOS)
|
||||||
|
- The metadata file contains:
|
||||||
|
```yaml
|
||||||
|
version: 2.0.3
|
||||||
|
releaseDate: '2024-01-15T10:30:00.000Z'
|
||||||
|
path: Hytale-F2P-Launcher-2.0.3-x64.exe
|
||||||
|
sha512: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Version Comparison**: electron-updater uses semantic versioning comparison:
|
||||||
|
- Compares the **current version** (from `package.json`) with the **latest version** (from GitHub Releases)
|
||||||
|
- Uses semantic versioning rules: `major.minor.patch` (e.g., `2.0.2` vs `2.0.3`)
|
||||||
|
- An update is available if the remote version is **greater than** the current version
|
||||||
|
- Examples:
|
||||||
|
- Current: `2.0.2` → Remote: `2.0.3` ✅ Update available
|
||||||
|
- Current: `2.0.2` → Remote: `2.0.2` ❌ No update (same version)
|
||||||
|
- Current: `2.0.3` → Remote: `2.0.2` ❌ No update (current is newer)
|
||||||
|
- Current: `2.0.2b` → Remote: `2.0.3` ✅ Update available (prerelease tags are handled)
|
||||||
|
|
||||||
|
4. **Version Format Handling**:
|
||||||
|
- **Semantic versions** (e.g., `1.0.0`, `2.1.3`) are compared numerically
|
||||||
|
- **Prerelease versions** (e.g., `2.0.2b`, `2.0.2-beta`) are compared with special handling
|
||||||
|
- **Non-semantic versions** may cause issues - it's recommended to use semantic versioning
|
||||||
|
|
||||||
|
### Update Metadata Files
|
||||||
|
|
||||||
|
When you build and publish a release, electron-builder generates platform-specific metadata files:
|
||||||
|
|
||||||
|
**Windows/Linux** (`latest.yml`):
|
||||||
|
```yaml
|
||||||
|
version: 2.0.3
|
||||||
|
files:
|
||||||
|
- url: Hytale-F2P-Launcher-2.0.3-x64.exe
|
||||||
|
sha512: abc123...
|
||||||
|
size: 12345678
|
||||||
|
path: Hytale-F2P-Launcher-2.0.3-x64.exe
|
||||||
|
sha512: abc123...
|
||||||
|
releaseDate: '2024-01-15T10:30:00.000Z'
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS** (`latest-mac.yml`):
|
||||||
|
```yaml
|
||||||
|
version: 2.0.3
|
||||||
|
files:
|
||||||
|
- url: Hytale-F2P-Launcher-2.0.3-arm64-mac.zip
|
||||||
|
sha512: def456...
|
||||||
|
size: 23456789
|
||||||
|
path: Hytale-F2P-Launcher-2.0.3-arm64-mac.zip
|
||||||
|
sha512: def456...
|
||||||
|
releaseDate: '2024-01-15T10:30:00.000Z'
|
||||||
|
```
|
||||||
|
|
||||||
|
These files are:
|
||||||
|
- Automatically generated during build
|
||||||
|
- Uploaded to GitHub Releases
|
||||||
|
- Fetched by electron-updater to check for updates
|
||||||
|
- Used to determine if an update is available and what to download
|
||||||
|
|
||||||
|
### The Check Process in Detail
|
||||||
|
|
||||||
|
When `appUpdater.checkForUpdatesAndNotify()` is called:
|
||||||
|
|
||||||
|
1. **Read Current Version**: Gets version from `app.getVersion()` (which reads from `package.json`)
|
||||||
|
2. **Fetch Update Info**:
|
||||||
|
- Makes HTTP request to GitHub Releases API or reads `latest.yml`
|
||||||
|
- Gets the version number from the metadata
|
||||||
|
3. **Compare Versions**:
|
||||||
|
- Uses semantic versioning comparison (e.g., `semver.gt(remoteVersion, currentVersion)`)
|
||||||
|
- If remote > current: update available
|
||||||
|
- If remote <= current: no update
|
||||||
|
4. **Emit Events**:
|
||||||
|
- `update-available` if newer version found
|
||||||
|
- `update-not-available` if already up to date
|
||||||
|
5. **Download if Available**: If `autoDownload` is enabled, starts downloading automatically
|
||||||
|
|
||||||
|
### Example Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
App Version: 2.0.2 (from package.json)
|
||||||
|
↓
|
||||||
|
Check GitHub Releases API
|
||||||
|
↓
|
||||||
|
Latest Release: 2.0.3
|
||||||
|
↓
|
||||||
|
Compare: 2.0.3 > 2.0.2? YES
|
||||||
|
↓
|
||||||
|
Emit: 'update-available' event
|
||||||
|
↓
|
||||||
|
Download update automatically
|
||||||
|
↓
|
||||||
|
Emit: 'update-downloaded' event
|
||||||
|
↓
|
||||||
|
User can install on next restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### AppUpdater Class (`backend/appUpdater.js`)
|
||||||
|
|
||||||
|
The main class that handles all update operations:
|
||||||
|
|
||||||
|
- **`checkForUpdatesAndNotify()`**: Checks for updates and shows a system notification if available
|
||||||
|
- **`checkForUpdates()`**: Manually checks for updates (returns a promise)
|
||||||
|
- **`quitAndInstall()`**: Quits the app and installs the downloaded update
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
The AppUpdater emits the following events that the UI can listen to:
|
||||||
|
|
||||||
|
- `update-checking`: Update check has started
|
||||||
|
- `update-available`: A new update is available
|
||||||
|
- `update-not-available`: App is up to date
|
||||||
|
- `update-download-progress`: Download progress updates
|
||||||
|
- `update-downloaded`: Update has finished downloading
|
||||||
|
- `update-error`: An error occurred during the update process
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Package.json
|
||||||
|
|
||||||
|
The publish configuration in `package.json` tells electron-builder where to publish updates:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"publish": {
|
||||||
|
"provider": "github",
|
||||||
|
"owner": "amiayweb",
|
||||||
|
"repo": "Hytale-F2P"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This means updates will be fetched from GitHub Releases for the `amiayweb/Hytale-F2P` repository.
|
||||||
|
|
||||||
|
## Publishing Updates
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
1. **Update Version**: Bump the version in `package.json` (e.g., `2.0.2b` → `2.0.3`)
|
||||||
|
|
||||||
|
2. **Build the App**: Run the build command for your platform:
|
||||||
|
```bash
|
||||||
|
npm run build:win # Windows
|
||||||
|
npm run build:mac # macOS
|
||||||
|
npm run build:linux # Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Publish to GitHub**: When building with electron-builder, it will:
|
||||||
|
- Generate update metadata files (`latest.yml`, `latest-mac.yml`, etc.)
|
||||||
|
- Upload the built files to GitHub Releases (if configured with `GH_TOKEN`)
|
||||||
|
- Make them available for auto-update
|
||||||
|
|
||||||
|
4. **Release on GitHub**: Create a GitHub Release with the new version tag
|
||||||
|
|
||||||
|
### Important Notes
|
||||||
|
|
||||||
|
- **macOS Code Signing**: macOS apps **must** be code-signed for auto-updates to work
|
||||||
|
- **Version Format**: Use semantic versioning (e.g., `1.0.0`, `2.0.1`) for best compatibility
|
||||||
|
- **Update Files**: electron-builder automatically generates the required metadata files (`latest.yml`, etc.)
|
||||||
|
|
||||||
|
## Testing Updates
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
To test updates during development, create a `dev-app-update.yml` file in the project root:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
owner: amiayweb
|
||||||
|
repo: Hytale-F2P
|
||||||
|
provider: github
|
||||||
|
```
|
||||||
|
|
||||||
|
Then enable dev mode in the code:
|
||||||
|
```javascript
|
||||||
|
autoUpdater.forceDevUpdateConfig = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Testing
|
||||||
|
|
||||||
|
For local testing, you can use a local server (like Minio) or a generic HTTP server to host update files.
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### What Users See
|
||||||
|
|
||||||
|
1. **On Startup**: The app silently checks for updates in the background
|
||||||
|
2. **Update Available**: A notification appears if an update is found
|
||||||
|
3. **Downloading**: Progress bar shows download status
|
||||||
|
4. **Ready to Install**: User is notified when the update is ready
|
||||||
|
5. **Installation**: Update installs on app restart or when user clicks "Install Now"
|
||||||
|
|
||||||
|
### User Actions
|
||||||
|
|
||||||
|
- Users can manually check for updates through the settings/update menu
|
||||||
|
- Users can choose to install immediately or wait until next app launch
|
||||||
|
- Users can continue using the app while updates download in the background
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Updates Not Working
|
||||||
|
|
||||||
|
1. **Check GitHub Releases**: Ensure releases are published on GitHub
|
||||||
|
2. **Check Version**: Make sure the version in `package.json` is higher than the current release
|
||||||
|
3. **Check Logs**: Check the app logs for update-related errors
|
||||||
|
4. **Code Signing (macOS)**: Verify the app is properly code-signed
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
- **"Update not available"**: Version in `package.json` may not be higher than the current release
|
||||||
|
- **"Download failed"**: Network issues or GitHub API rate limits
|
||||||
|
- **"Installation failed"**: Permissions issue or app is running from an unsupported location
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Supported Platforms
|
||||||
|
|
||||||
|
- **Windows**: NSIS installer (auto-update supported)
|
||||||
|
- **macOS**: DMG + ZIP (auto-update supported, requires code signing)
|
||||||
|
- **Linux**: AppImage, DEB, RPM, Pacman (auto-update supported)
|
||||||
|
|
||||||
|
### Update Files Generated
|
||||||
|
|
||||||
|
When building, electron-builder generates:
|
||||||
|
- `latest.yml` (Windows/Linux)
|
||||||
|
- `latest-mac.yml` (macOS)
|
||||||
|
- `latest-linux.yml` (Linux)
|
||||||
|
|
||||||
|
These files contain metadata about the latest release and are automatically uploaded to GitHub Releases.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [electron-updater Documentation](https://www.electron.build/auto-update)
|
||||||
|
- [electron-builder Auto Update Guide](https://www.electron.build/auto-update)
|
||||||
39
docs/BUILD.md
Normal file
39
docs/BUILD.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Build Instructions
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Build for current platform:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build for specific platform:
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
```bash
|
||||||
|
npm run build:win
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux:**
|
||||||
|
```bash
|
||||||
|
npm run build:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
```bash
|
||||||
|
npm run build:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build for all platforms:
|
||||||
|
```bash
|
||||||
|
npm run build:all
|
||||||
|
```
|
||||||
|
|
||||||
|
Built executables will be in the `dist/` directory
|
||||||
78
docs/CLEAR-UPDATE-CACHE.md
Normal file
78
docs/CLEAR-UPDATE-CACHE.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Clearing Electron-Updater Cache
|
||||||
|
|
||||||
|
To force electron-updater to re-download an update file, you need to clear the cached download.
|
||||||
|
|
||||||
|
## Quick Method (Terminal)
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
```bash
|
||||||
|
# Remove the entire cache directory
|
||||||
|
rm -rf ~/Library/Caches/hytale-f2p-launcher
|
||||||
|
|
||||||
|
# Or just remove pending downloads
|
||||||
|
rm -rf ~/Library/Caches/hytale-f2p-launcher/pending
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
```bash
|
||||||
|
# Remove the entire cache directory
|
||||||
|
rmdir /s "%LOCALAPPDATA%\hytale-f2p-launcher-updater"
|
||||||
|
|
||||||
|
# Or just remove pending downloads
|
||||||
|
rmdir /s "%LOCALAPPDATA%\hytale-f2p-launcher-updater\pending"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
```bash
|
||||||
|
# Remove the entire cache directory
|
||||||
|
rm -rf ~/.cache/hytale-f2p-launcher-updater
|
||||||
|
|
||||||
|
# Or just remove pending downloads
|
||||||
|
rm -rf ~/.cache/hytale-f2p-launcher-updater/pending
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cache Locations
|
||||||
|
|
||||||
|
electron-updater stores downloaded updates in:
|
||||||
|
|
||||||
|
- **macOS**: `~/Library/Caches/hytale-f2p-launcher/`
|
||||||
|
- **Windows**: `%LOCALAPPDATA%\hytale-f2p-launcher-updater\`
|
||||||
|
- **Linux**: `~/.cache/hytale-f2p-launcher-updater/`
|
||||||
|
|
||||||
|
The cache typically contains:
|
||||||
|
- `pending/` - Downloaded update files waiting to be installed
|
||||||
|
- Metadata files about available updates
|
||||||
|
|
||||||
|
## After Clearing
|
||||||
|
|
||||||
|
After clearing the cache:
|
||||||
|
1. Restart the launcher
|
||||||
|
2. It will check for updates again
|
||||||
|
3. The update will be re-downloaded from scratch
|
||||||
|
|
||||||
|
## Programmatic Method
|
||||||
|
|
||||||
|
You can also clear the cache programmatically by adding this to your code:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { autoUpdater } = require('electron-updater');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
function clearUpdateCache() {
|
||||||
|
const cacheDir = path.join(
|
||||||
|
os.homedir(),
|
||||||
|
process.platform === 'win32'
|
||||||
|
? 'AppData/Local/hytale-f2p-launcher-updater'
|
||||||
|
: process.platform === 'darwin'
|
||||||
|
? 'Library/Caches/hytale-f2p-launcher'
|
||||||
|
: '.cache/hytale-f2p-launcher-updater'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fs.existsSync(cacheDir)) {
|
||||||
|
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||||
|
console.log('Update cache cleared');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
231
docs/STEAMDECK_CRASH_INVESTIGATION.md
Normal file
231
docs/STEAMDECK_CRASH_INVESTIGATION.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# Steam Deck / Ubuntu LTS Crash Investigation
|
||||||
|
|
||||||
|
## Status: UNSOLVED
|
||||||
|
|
||||||
|
**Last updated:** 2026-01-27
|
||||||
|
|
||||||
|
No stable solution found. jemalloc helps occasionally but crashes still occur randomly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Summary
|
||||||
|
|
||||||
|
The Hytale F2P launcher's client patcher causes crashes on Steam Deck and Ubuntu LTS with the error:
|
||||||
|
```
|
||||||
|
free(): invalid pointer
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```
|
||||||
|
SIGSEGV (Segmentation fault)
|
||||||
|
```
|
||||||
|
|
||||||
|
The crash occurs after successful authentication, specifically right after "Finished handling RequiredAssets".
|
||||||
|
|
||||||
|
**Affected Systems:**
|
||||||
|
- Steam Deck (glibc 2.41)
|
||||||
|
- Ubuntu LTS
|
||||||
|
|
||||||
|
**Working Systems:**
|
||||||
|
- macOS
|
||||||
|
- Windows
|
||||||
|
- Older Arch Linux (glibc < 2.41)
|
||||||
|
|
||||||
|
**Critical Finding:** The UNPATCHED original binary works fine on Steam Deck. The crash is caused by ANY binary patching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Was Tried (All Failed)
|
||||||
|
|
||||||
|
### Memory Allocators
|
||||||
|
| Approach | Result |
|
||||||
|
|----------|--------|
|
||||||
|
| `LD_PRELOAD=/usr/lib/libjemalloc.so.2` | Works randomly (3/10 times), not stable |
|
||||||
|
| `MALLOC_CHECK_=0` | No effect |
|
||||||
|
| `MALLOC_PERTURB_=255` | No effect |
|
||||||
|
| `GLIBC_TUNABLES=glibc.malloc.tcache_count=0` | No effect |
|
||||||
|
|
||||||
|
### Process/Scheduling
|
||||||
|
| Approach | Result |
|
||||||
|
|----------|--------|
|
||||||
|
| `taskset -c 0` (single core) | Game too slow, stuck at connecting |
|
||||||
|
| `taskset -c 0,1` or `0-3` | Still crashes |
|
||||||
|
| `nice -n 19` | No effect |
|
||||||
|
| `chrt --idle 0` | No effect |
|
||||||
|
| `strace -f` | No effect |
|
||||||
|
|
||||||
|
### Linker/Loading
|
||||||
|
| Approach | Result |
|
||||||
|
|----------|--------|
|
||||||
|
| `LD_BIND_NOW=1` | No effect |
|
||||||
|
| Wrapper script with LD_PRELOAD | No effect |
|
||||||
|
| Shell spawn with inline LD_PRELOAD | No effect |
|
||||||
|
|
||||||
|
### Patching Variations
|
||||||
|
| Approach | Result |
|
||||||
|
|----------|--------|
|
||||||
|
| Null-padding after replacement | Crashes (made it worse) |
|
||||||
|
| No null-padding (develop behavior) | Still crashes |
|
||||||
|
| Minimal patches (3 instead of 6) | Still crashes |
|
||||||
|
| Ultra-minimal (1 patch - domain only) | Still crashes |
|
||||||
|
| Skip sentry patch | Still crashes |
|
||||||
|
| Skip subdomain patches | Still crashes |
|
||||||
|
|
||||||
|
**Key Finding:** Even patching just 1 string (main domain only) causes the crash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## String Occurrences Found
|
||||||
|
|
||||||
|
### Length-Prefixed Format
|
||||||
|
Found by default patcher mode:
|
||||||
|
|
||||||
|
| Offset | Content | Notes |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| 0x1bc5d63 | `hytale.com` | **Surrounded by x86 code!** |
|
||||||
|
|
||||||
|
### UTF-16LE Format (3 occurrences)
|
||||||
|
| Offset | Content |
|
||||||
|
|--------|---------|
|
||||||
|
| 0x1bc5ad7 | `sentry.hytale.com/...` |
|
||||||
|
| 0x1bc5b3f | `https://hytale.com/help...` |
|
||||||
|
| 0x1bc5bc9 | `store.hytale.com/?...` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Binary Analysis
|
||||||
|
|
||||||
|
When patching with length-prefixed mode:
|
||||||
|
|
||||||
|
```
|
||||||
|
< 01bc5d60: 5933 b80a 0000 0068 0079 0074 0061 006c Y3.....h.y.t.a.l
|
||||||
|
< 01bc5d70: 0065 002e 0063 006f 006d 8933 8807 0000 .e...c.o.m.3....
|
||||||
|
---
|
||||||
|
> 01bc5d60: 5933 b80a 0000 0073 0061 006e 0061 0073 Y3.....s.a.n.a.s
|
||||||
|
> 01bc5d70: 006f 006c 002e 0077 0073 8933 8807 0000 .o.l...w.s.3....
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```
|
||||||
|
5933 b8 | 0a000000 | h.y.t.a.l.e...c.o.m | 8933 8807 0000
|
||||||
|
???????? | len=10 | string content | mov [rbx],esi?
|
||||||
|
```
|
||||||
|
|
||||||
|
- `5933 b8` before string - could be code or metadata
|
||||||
|
- `0a 00 00 00` - .NET length prefix (10 characters)
|
||||||
|
- String content in UTF-16LE
|
||||||
|
- `89 33` after - this is `mov [rbx], esi` in x86-64!
|
||||||
|
|
||||||
|
**The string is embedded near executable code, not in a clean data section.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GDB Stack Trace
|
||||||
|
|
||||||
|
```
|
||||||
|
#0 0x00007ffff7d3f5a4 in ?? () from /usr/lib/libc.so.6
|
||||||
|
#1 raise () from /usr/lib/libc.so.6
|
||||||
|
#2 abort () from /usr/lib/libc.so.6
|
||||||
|
#3-#4 ?? () from /usr/lib/libc.so.6
|
||||||
|
#5 free () from /usr/lib/libc.so.6
|
||||||
|
#6 ?? () from libzstd.so <-- CRASH POINT
|
||||||
|
#7-#24 HytaleClient code (asset decompression)
|
||||||
|
```
|
||||||
|
|
||||||
|
Crash occurs in `libzstd.so` during `free()` after "Finished handling RequiredAssets".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hypotheses
|
||||||
|
|
||||||
|
### 1. .NET AOT String Metadata (Most Likely)
|
||||||
|
.NET AOT may have precomputed hashes, checksums, or relocation info for strings. Modifying string content breaks internal consistency, causing memory corruption when the runtime tries to use related data structures.
|
||||||
|
|
||||||
|
### 2. Code/Data Interleaving
|
||||||
|
The strings are embedded near x86 code (`89 33` = `mov [rbx], esi`). .NET AOT may use relative offsets that get invalidated when we modify nearby bytes.
|
||||||
|
|
||||||
|
### 3. Binary Checksums
|
||||||
|
The binary may have integrity checks for certain sections that we're invalidating by patching.
|
||||||
|
|
||||||
|
### 4. Timing-Dependent Race Condition
|
||||||
|
The fact that it works randomly (~30% of the time with jemalloc) suggests a race condition that's affected by:
|
||||||
|
- Memory layout changes from patching
|
||||||
|
- Allocator behavior differences
|
||||||
|
- CPU scheduling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Valgrind Results (Misleading)
|
||||||
|
|
||||||
|
- Valgrind showed NO memory corruption errors
|
||||||
|
- Game ran successfully under Valgrind (slower execution)
|
||||||
|
- This suggested jemalloc would fix it, but it doesn't consistently work
|
||||||
|
|
||||||
|
The slowdown from Valgrind likely masks the race condition timing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Launcher Implementation
|
||||||
|
|
||||||
|
The launcher attempts:
|
||||||
|
1. Auto-detect jemalloc at common paths
|
||||||
|
2. Auto-install jemalloc via pkexec if not found
|
||||||
|
3. Launch game with `LD_PRELOAD` via shell command
|
||||||
|
|
||||||
|
But this doesn't provide stable results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Potential Alternative Approaches (Not Yet Tried)
|
||||||
|
|
||||||
|
### 1. LD_PRELOAD Network Hooking
|
||||||
|
Instead of patching the binary, hook `getaddrinfo()` / `connect()` to redirect network calls at runtime. No binary modification needed.
|
||||||
|
|
||||||
|
### 2. Local Proxy + Certificate
|
||||||
|
Run a local HTTPS proxy that intercepts hytale.com traffic and redirects to custom server. Requires installing a custom CA certificate.
|
||||||
|
|
||||||
|
### 3. DNS + iptables Redirect
|
||||||
|
Use local DNS to resolve hytale.com to localhost, then iptables to redirect to actual custom server. Requires root/sudo.
|
||||||
|
|
||||||
|
### 4. Container with Older glibc
|
||||||
|
Run the game in a container with glibc < 2.41 where the stricter validation doesn't exist.
|
||||||
|
|
||||||
|
### 5. Different Patching Location
|
||||||
|
Find strings in a pure data section rather than code-adjacent areas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Reference
|
||||||
|
|
||||||
|
**Binary:** `HytaleClient` (ELF 64-bit, ~39.9 MB)
|
||||||
|
|
||||||
|
**Branch:** `fix/steamdeck-jemalloc-crash`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Install jemalloc (Partial Mitigation)
|
||||||
|
|
||||||
|
jemalloc may help in some cases (~30% success rate):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Steam Deck / Arch Linux
|
||||||
|
sudo pacman -S jemalloc
|
||||||
|
|
||||||
|
# Ubuntu / Debian
|
||||||
|
sudo apt install libjemalloc2
|
||||||
|
|
||||||
|
# Fedora / RHEL
|
||||||
|
sudo dnf install jemalloc
|
||||||
|
```
|
||||||
|
|
||||||
|
The launcher automatically uses jemalloc if found. To disable:
|
||||||
|
```bash
|
||||||
|
HYTALE_NO_JEMALLOC=1 npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**No stable solution found.** The binary patching approach may be fundamentally incompatible with glibc 2.41's stricter heap validation when modifying .NET AOT compiled binaries.
|
||||||
|
|
||||||
|
Alternative approaches (network hooking, proxy, container) may be required for reliable Steam Deck / Ubuntu LTS support.
|
||||||
95
docs/STEAMDECK_DEBUG_COMMANDS.md
Normal file
95
docs/STEAMDECK_DEBUG_COMMANDS.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Steam Deck / Linux Crash Fix
|
||||||
|
|
||||||
|
## SOLUTION: Use jemalloc ✓
|
||||||
|
|
||||||
|
The crash is caused by glibc 2.41's stricter heap validation. Using jemalloc as the memory allocator fixes the issue.
|
||||||
|
|
||||||
|
### Install jemalloc
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Steam Deck / Arch Linux
|
||||||
|
sudo pacman -S jemalloc
|
||||||
|
|
||||||
|
# Ubuntu / Debian
|
||||||
|
sudo apt install libjemalloc2
|
||||||
|
|
||||||
|
# Fedora / RHEL
|
||||||
|
sudo dnf install jemalloc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Launcher Auto-Detection
|
||||||
|
|
||||||
|
The launcher automatically uses jemalloc when installed. No manual configuration needed.
|
||||||
|
|
||||||
|
To disable (for testing):
|
||||||
|
```bash
|
||||||
|
HYTALE_NO_JEMALLOC=1 npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Launch with jemalloc
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.hytalef2p/release/package/game/latest
|
||||||
|
|
||||||
|
LD_PRELOAD=/usr/lib/libjemalloc.so.2 ./Client/HytaleClient --app-dir /home/deck/.hytalef2p/release/package/game/latest --java-exec /home/deck/.hytalef2p/release/package/jre/latest/bin/java --auth-mode authenticated --uuid YOUR_UUID --name Player --identity-token YOUR_TOKEN --session-token YOUR_TOKEN --user-dir /home/deck/.hytalesaves
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug Commands (for troubleshooting)
|
||||||
|
|
||||||
|
### Base Command
|
||||||
|
```bash
|
||||||
|
cd ~/.hytalef2p/release/package/game/latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### GDB Stack Trace (for crash analysis)
|
||||||
|
```bash
|
||||||
|
gdb -ex "run --app-dir ..." ./Client/HytaleClient
|
||||||
|
|
||||||
|
# After crash:
|
||||||
|
bt
|
||||||
|
bt full
|
||||||
|
info registers
|
||||||
|
quit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test glibc tunables (alternative fixes that didn't work reliably)
|
||||||
|
|
||||||
|
**Disable tcache:**
|
||||||
|
```bash
|
||||||
|
GLIBC_TUNABLES=glibc.malloc.tcache_count=0 ./Client/HytaleClient ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disable heap validation:**
|
||||||
|
```bash
|
||||||
|
MALLOC_CHECK_=0 ./Client/HytaleClient ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Binary Validation
|
||||||
|
```bash
|
||||||
|
file ~/.hytalef2p/release/package/game/latest/Client/HytaleClient
|
||||||
|
ldd ~/.hytalef2p/release/package/game/latest/Client/HytaleClient
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hex Dump Commands
|
||||||
|
```bash
|
||||||
|
# Search for hytale.com UTF-16LE
|
||||||
|
xxd ~/.hytalef2p/release/package/game/latest/Client/HytaleClient.original | grep "6800 7900 7400 6100 6c00 6500 2e00 6300 6f00 6d00"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Different Patch Modes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restore original
|
||||||
|
cp ~/.hytalef2p/release/package/game/latest/Client/HytaleClient.original ~/.hytalef2p/release/package/game/latest/Client/HytaleClient
|
||||||
|
rm ~/.hytalef2p/release/package/game/latest/Client/HytaleClient.patched_custom
|
||||||
|
|
||||||
|
# Test UTF-16LE mode
|
||||||
|
HYTALE_PATCH_MODE=utf16le HYTALE_AUTH_DOMAIN=sanasol.ws npm start
|
||||||
|
|
||||||
|
# Test length-prefixed mode (default)
|
||||||
|
HYTALE_AUTH_DOMAIN=sanasol.ws npm start
|
||||||
|
```
|
||||||
196
docs/TESTING-UPDATES.md
Normal file
196
docs/TESTING-UPDATES.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Testing Auto-Updates
|
||||||
|
|
||||||
|
This guide explains how to test the auto-update system during development.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Option 1: Test with GitHub Releases (Easiest)
|
||||||
|
|
||||||
|
1. **Set up dev-app-update.yml** (already done):
|
||||||
|
```yaml
|
||||||
|
provider: github
|
||||||
|
owner: amiayweb
|
||||||
|
repo: Hytale-F2P
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Lower your current version** in `package.json`:
|
||||||
|
- Change version to something lower than what's on GitHub (e.g., `2.0.1` if GitHub has `2.0.3`)
|
||||||
|
|
||||||
|
3. **Run the app in dev mode**:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **The app will check for updates** 3 seconds after startup
|
||||||
|
- If a newer version exists on GitHub, it will detect it
|
||||||
|
- Check the console logs for update messages
|
||||||
|
|
||||||
|
### Option 2: Test with Local HTTP Server
|
||||||
|
|
||||||
|
For more control, you can set up a local server:
|
||||||
|
|
||||||
|
1. **Create a test update server**:
|
||||||
|
```bash
|
||||||
|
# Create a test directory
|
||||||
|
mkdir -p test-updates
|
||||||
|
cd test-updates
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Build a test version** with a higher version number:
|
||||||
|
```bash
|
||||||
|
# In package.json, set version to 2.0.4
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Copy the generated files** to your test server:
|
||||||
|
- Copy `dist/latest.yml` (or `latest-mac.yml` for macOS)
|
||||||
|
- Copy the built installer/package
|
||||||
|
|
||||||
|
4. **Start a simple HTTP server**:
|
||||||
|
```bash
|
||||||
|
# Using Python
|
||||||
|
python3 -m http.server 8080
|
||||||
|
|
||||||
|
# Or using Node.js http-server
|
||||||
|
npx http-server -p 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Update dev-app-update.yml** to point to local server:
|
||||||
|
```yaml
|
||||||
|
provider: generic
|
||||||
|
url: http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Run the app** and it will check your local server
|
||||||
|
|
||||||
|
## Testing Steps
|
||||||
|
|
||||||
|
### 1. Prepare Test Environment
|
||||||
|
|
||||||
|
**Current version**: `2.0.3` (in package.json)
|
||||||
|
**Test version**: `2.0.4` (on GitHub or local server)
|
||||||
|
|
||||||
|
### 2. Run the App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Watch for Update Events
|
||||||
|
|
||||||
|
The app will automatically check for updates 3 seconds after startup. Watch the console for:
|
||||||
|
|
||||||
|
```
|
||||||
|
Checking for updates...
|
||||||
|
Update available: 2.0.4
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Check Console Logs
|
||||||
|
|
||||||
|
Look for these messages:
|
||||||
|
- `Checking for updates...` - Update check started
|
||||||
|
- `Update available: 2.0.4` - New version found
|
||||||
|
- `Download speed: ...` - Download progress
|
||||||
|
- `Update downloaded: 2.0.4` - Download complete
|
||||||
|
|
||||||
|
### 5. Test UI Integration
|
||||||
|
|
||||||
|
The app sends these events to the renderer:
|
||||||
|
- `update-checking`
|
||||||
|
- `update-available` (with version info)
|
||||||
|
- `update-download-progress` (with progress data)
|
||||||
|
- `update-downloaded` (ready to install)
|
||||||
|
|
||||||
|
You can listen to these in your frontend code to show update notifications.
|
||||||
|
|
||||||
|
## Manual Testing
|
||||||
|
|
||||||
|
### Trigger Manual Update Check
|
||||||
|
|
||||||
|
You can also trigger a manual check via IPC:
|
||||||
|
```javascript
|
||||||
|
// In renderer process
|
||||||
|
const result = await window.electronAPI.invoke('check-for-updates');
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install Update
|
||||||
|
|
||||||
|
After an update is downloaded:
|
||||||
|
```javascript
|
||||||
|
// In renderer process
|
||||||
|
await window.electronAPI.invoke('quit-and-install-update');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Update Available
|
||||||
|
1. Set `package.json` version to `2.0.1`
|
||||||
|
2. Ensure GitHub has version `2.0.3` or higher
|
||||||
|
3. Run app → Should detect update
|
||||||
|
|
||||||
|
### Scenario 2: Already Up to Date
|
||||||
|
1. Set `package.json` version to `2.0.3`
|
||||||
|
2. Ensure GitHub has version `2.0.3` or lower
|
||||||
|
3. Run app → Should show "no update available"
|
||||||
|
|
||||||
|
### Scenario 3: Prerelease Version
|
||||||
|
1. Set `package.json` version to `2.0.2b`
|
||||||
|
2. Ensure GitHub has version `2.0.3`
|
||||||
|
3. Run app → Should detect update (prerelease < release)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Update Not Detected
|
||||||
|
|
||||||
|
1. **Check dev-app-update.yml exists** in project root
|
||||||
|
2. **Verify dev mode is enabled** - Check console for "Dev update mode enabled"
|
||||||
|
3. **Check version numbers** - Remote version must be higher than current
|
||||||
|
4. **Check network** - App needs internet to reach GitHub/local server
|
||||||
|
5. **Check logs** - Look for error messages in console
|
||||||
|
|
||||||
|
### Common Errors
|
||||||
|
|
||||||
|
- **"Cannot find module 'electron-updater'"**: Run `npm install`
|
||||||
|
- **"Update check failed"**: Check network connection or GitHub API access
|
||||||
|
- **"No update available"**: Version comparison issue - check versions
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Enable more verbose logging by checking the console output. The logger will show:
|
||||||
|
- Update check requests
|
||||||
|
- Version comparisons
|
||||||
|
- Download progress
|
||||||
|
- Any errors
|
||||||
|
|
||||||
|
## Testing with Real GitHub Releases
|
||||||
|
|
||||||
|
For the most realistic test:
|
||||||
|
|
||||||
|
1. **Create a test release on GitHub**:
|
||||||
|
- Build the app with version `2.0.4`
|
||||||
|
- Create a GitHub release with tag `v2.0.4`
|
||||||
|
- Upload the built files
|
||||||
|
|
||||||
|
2. **Lower your local version**:
|
||||||
|
- Set `package.json` to `2.0.3`
|
||||||
|
|
||||||
|
3. **Run the app**:
|
||||||
|
- It will check GitHub and find `2.0.4`
|
||||||
|
- Download and install the update
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Dev mode only works when app is NOT packaged** (`!app.isPackaged`)
|
||||||
|
- **Production builds** ignore `dev-app-update.yml` and use the built-in `app-update.yml`
|
||||||
|
- **macOS**: Code signing is required for updates to work in production
|
||||||
|
- **Windows**: NSIS installer is required for auto-updates
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Once testing is complete:
|
||||||
|
1. Remove or comment out `forceDevUpdateConfig` for production
|
||||||
|
2. Ensure proper code signing for macOS
|
||||||
|
3. Set up CI/CD to automatically publish releases
|
||||||
9966
package-lock.json
generated
9966
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
272
package.json
272
package.json
@@ -1,124 +1,148 @@
|
|||||||
{
|
{
|
||||||
"name": "hytale-f2p-launcher",
|
"name": "hytale-f2p-launcher",
|
||||||
"version": "2.0.1",
|
"version": "2.1.2",
|
||||||
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
||||||
"homepage": "https://github.com/amiayweb/Hytale-F2P",
|
"homepage": "https://github.com/amiayweb/Hytale-F2P",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"dev": "electron . --dev",
|
"dev": "electron . --dev",
|
||||||
"build": "electron-builder",
|
"build": "electron-builder",
|
||||||
"build:win": "electron-builder --win",
|
"build:win": "electron-builder --win",
|
||||||
"build:linux": "electron-builder --linux",
|
"build:linux": "electron-builder --linux",
|
||||||
"build:mac": "electron-builder --mac",
|
"build:mac": "electron-builder --mac",
|
||||||
"build:all": "electron-builder --win --linux --mac"
|
"build:all": "electron-builder --win --linux --mac",
|
||||||
},
|
"build:arch": "electron-builder --linux dir",
|
||||||
"keywords": [
|
"build:appimage": "electron-builder --linux AppImage --publish never",
|
||||||
"hytale",
|
"build:deb": "electron-builder --linux deb --publish never",
|
||||||
"launcher",
|
"build:rpm": "electron-builder --linux rpm --publish never"
|
||||||
"game",
|
},
|
||||||
"client",
|
"keywords": [
|
||||||
"cross-platform",
|
"hytale",
|
||||||
"electron",
|
"launcher",
|
||||||
"auto-update",
|
"game",
|
||||||
"mod-manager",
|
"client",
|
||||||
"chat"
|
"cross-platform",
|
||||||
],
|
"electron",
|
||||||
"author": {
|
"auto-update",
|
||||||
"name": "AMIAY",
|
"mod-manager",
|
||||||
"email": "support@amiay.dev"
|
"chat"
|
||||||
},
|
],
|
||||||
"license": "MIT",
|
"maintainers": [
|
||||||
"devDependencies": {
|
{
|
||||||
"electron": "^40.0.0",
|
"name": "Terromur",
|
||||||
"electron-builder": "^26.4.0"
|
"url": "https://github.com/Terromur"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
{
|
||||||
"adm-zip": "^0.5.10",
|
"name": "Fari Gading",
|
||||||
"axios": "^1.6.0",
|
"email": "fazrigading@gmail.com",
|
||||||
"discord-rpc": "^4.0.1",
|
"url": "https://github.com/fazrigading"
|
||||||
"tar": "^6.2.1",
|
}
|
||||||
"uuid": "^9.0.1"
|
],
|
||||||
},
|
"author": {
|
||||||
"overrides": {
|
"name": "AMIAY",
|
||||||
"tar": "$tar"
|
"email": "support@amiay.dev"
|
||||||
},
|
},
|
||||||
"build": {
|
"license": "MIT",
|
||||||
"appId": "com.hytalef2p.launcher",
|
"devDependencies": {
|
||||||
"productName": "Hytale F2P",
|
"electron": "^40.0.0",
|
||||||
"directories": {
|
"electron-builder": "^26.4.0"
|
||||||
"output": "dist"
|
},
|
||||||
},
|
"dependencies": {
|
||||||
"files": [
|
"adm-zip": "^0.5.10",
|
||||||
"main.js",
|
"axios": "^1.6.0",
|
||||||
"preload.js",
|
"discord-rpc": "^4.0.1",
|
||||||
"backend/**/*",
|
"dotenv": "^17.2.3",
|
||||||
"GUI/**/*",
|
"electron-updater": "^6.7.3",
|
||||||
"package.json"
|
"fs-extra": "^11.3.3",
|
||||||
],
|
"tar": "^6.2.1",
|
||||||
"win": {
|
"uuid": "^9.0.1"
|
||||||
"target": [
|
},
|
||||||
{
|
"overrides": {
|
||||||
"target": "nsis",
|
"tar": "$tar"
|
||||||
"arch": [
|
},
|
||||||
"x64"
|
"build": {
|
||||||
]
|
"appId": "com.hytalef2p.launcher",
|
||||||
},
|
"productName": "Hytale F2P Launcher",
|
||||||
{
|
"artifactName": "${name}_${version}_${arch}.${ext}",
|
||||||
"target": "portable",
|
"directories": {
|
||||||
"arch": [
|
"output": "dist"
|
||||||
"x64"
|
},
|
||||||
]
|
"files": [
|
||||||
}
|
"main.js",
|
||||||
],
|
"preload.js",
|
||||||
"icon": "icon.ico"
|
"backend/**/*",
|
||||||
},
|
"GUI/**/*",
|
||||||
"linux": {
|
"package.json",
|
||||||
"target": [
|
".env"
|
||||||
{
|
],
|
||||||
"target": "AppImage",
|
"win": {
|
||||||
"arch": [
|
"target": [
|
||||||
"x64"
|
{
|
||||||
]
|
"target": "nsis",
|
||||||
},
|
"arch": [
|
||||||
{
|
"x64",
|
||||||
"target": "deb",
|
"arm64"
|
||||||
"arch": [
|
]
|
||||||
"x64"
|
}
|
||||||
]
|
],
|
||||||
}
|
"icon": "build/icon.ico"
|
||||||
],
|
},
|
||||||
"icon": "build/icon.png",
|
"linux": {
|
||||||
"category": "Game",
|
"target": [
|
||||||
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support"
|
{
|
||||||
},
|
"target": "AppImage",
|
||||||
"mac": {
|
"arch": [
|
||||||
"target": [
|
"x64",
|
||||||
{
|
"arm64"
|
||||||
"target": "dmg",
|
]
|
||||||
"arch": [
|
},
|
||||||
"x64",
|
{
|
||||||
"arm64"
|
"target": "deb",
|
||||||
]
|
"arch": [
|
||||||
},
|
"x64",
|
||||||
{
|
"arm64"
|
||||||
"target": "zip",
|
]
|
||||||
"arch": [
|
},
|
||||||
"universal"
|
{
|
||||||
]
|
"target": "rpm",
|
||||||
}
|
"arch": [
|
||||||
],
|
"x64",
|
||||||
"icon": "build/icon.icns",
|
"arm64"
|
||||||
"category": "public.app-category.games"
|
]
|
||||||
},
|
}
|
||||||
"nsis": {
|
],
|
||||||
"oneClick": false,
|
"icon": "build/icon.png",
|
||||||
"allowToChangeInstallationDirectory": true,
|
"category": "Game"
|
||||||
"createDesktopShortcut": true,
|
},
|
||||||
"createStartMenuShortcut": true
|
"mac": {
|
||||||
}
|
"target": [
|
||||||
}
|
{
|
||||||
}
|
"target": "dmg",
|
||||||
|
"arch": [
|
||||||
|
"universal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "zip",
|
||||||
|
"arch": [
|
||||||
|
"universal"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icon": "build/icon.icns",
|
||||||
|
"category": "public.app-category.games"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"provider": "github",
|
||||||
|
"owner": "amiayweb",
|
||||||
|
"repo": "Hytale-F2P"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
87
preload.js
87
preload.js
@@ -1,28 +1,46 @@
|
|||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
launchGame: (playerName, javaPath, installPath) => ipcRenderer.invoke('launch-game', playerName, javaPath, installPath),
|
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'),
|
closeWindow: () => ipcRenderer.invoke('window-close'),
|
||||||
minimizeWindow: () => ipcRenderer.invoke('window-minimize'),
|
minimizeWindow: () => ipcRenderer.invoke('window-minimize'),
|
||||||
|
maximizeWindow: () => ipcRenderer.invoke('window-maximize'),
|
||||||
|
getVersion: () => ipcRenderer.invoke('get-version'),
|
||||||
saveUsername: (username) => ipcRenderer.invoke('save-username', username),
|
saveUsername: (username) => ipcRenderer.invoke('save-username', username),
|
||||||
loadUsername: () => ipcRenderer.invoke('load-username'),
|
loadUsername: () => ipcRenderer.invoke('load-username'),
|
||||||
saveChatUsername: (chatUsername) => ipcRenderer.invoke('save-chat-username', chatUsername),
|
saveChatUsername: (chatUsername) => ipcRenderer.invoke('save-chat-username', chatUsername),
|
||||||
loadChatUsername: () => ipcRenderer.invoke('load-chat-username'),
|
loadChatUsername: () => ipcRenderer.invoke('load-chat-username'),
|
||||||
|
saveChatColor: (chatColor) => ipcRenderer.invoke('save-chat-color', chatColor),
|
||||||
|
loadChatColor: () => ipcRenderer.invoke('load-chat-color'),
|
||||||
saveJavaPath: (javaPath) => ipcRenderer.invoke('save-java-path', javaPath),
|
saveJavaPath: (javaPath) => ipcRenderer.invoke('save-java-path', javaPath),
|
||||||
loadJavaPath: () => ipcRenderer.invoke('load-java-path'),
|
loadJavaPath: () => ipcRenderer.invoke('load-java-path'),
|
||||||
saveInstallPath: (installPath) => ipcRenderer.invoke('save-install-path', installPath),
|
saveInstallPath: (installPath) => ipcRenderer.invoke('save-install-path', installPath),
|
||||||
loadInstallPath: () => ipcRenderer.invoke('load-install-path'),
|
loadInstallPath: () => ipcRenderer.invoke('load-install-path'),
|
||||||
|
saveDiscordRPC: (enabled) => ipcRenderer.invoke('save-discord-rpc', enabled),
|
||||||
|
loadDiscordRPC: () => ipcRenderer.invoke('load-discord-rpc'),
|
||||||
|
saveLanguage: (language) => ipcRenderer.invoke('save-language', language),
|
||||||
|
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'),
|
selectInstallPath: () => ipcRenderer.invoke('select-install-path'),
|
||||||
browseJavaPath: () => ipcRenderer.invoke('browse-java-path'),
|
browseJavaPath: () => ipcRenderer.invoke('browse-java-path'),
|
||||||
isGameInstalled: () => ipcRenderer.invoke('is-game-installed'),
|
isGameInstalled: () => ipcRenderer.invoke('is-game-installed'),
|
||||||
uninstallGame: () => ipcRenderer.invoke('uninstall-game'),
|
uninstallGame: () => ipcRenderer.invoke('uninstall-game'),
|
||||||
|
repairGame: () => ipcRenderer.invoke('repair-game'),
|
||||||
|
retryDownload: (retryData) => ipcRenderer.invoke('retry-download', retryData),
|
||||||
getHytaleNews: () => ipcRenderer.invoke('get-hytale-news'),
|
getHytaleNews: () => ipcRenderer.invoke('get-hytale-news'),
|
||||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||||
openExternalLink: (url) => ipcRenderer.invoke('openExternalLink', url),
|
openExternalLink: (url) => ipcRenderer.invoke('openExternalLink', url),
|
||||||
openGameLocation: () => ipcRenderer.invoke('open-game-location'),
|
openGameLocation: () => ipcRenderer.invoke('open-game-location'),
|
||||||
saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings),
|
saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings),
|
||||||
loadSettings: () => ipcRenderer.invoke('load-settings'),
|
loadSettings: () => ipcRenderer.invoke('load-settings'),
|
||||||
|
getEnvVar: (key) => ipcRenderer.invoke('get-env-var', key),
|
||||||
getLocalAppData: () => ipcRenderer.invoke('get-local-app-data'),
|
getLocalAppData: () => ipcRenderer.invoke('get-local-app-data'),
|
||||||
getModsPath: () => ipcRenderer.invoke('get-mods-path'),
|
getModsPath: () => ipcRenderer.invoke('get-mods-path'),
|
||||||
loadInstalledMods: (modsPath) => ipcRenderer.invoke('load-installed-mods', modsPath),
|
loadInstalledMods: (modsPath) => ipcRenderer.invoke('load-installed-mods', modsPath),
|
||||||
@@ -32,11 +50,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
selectModFiles: () => ipcRenderer.invoke('select-mod-files'),
|
selectModFiles: () => ipcRenderer.invoke('select-mod-files'),
|
||||||
copyModFile: (sourcePath, modsPath) => ipcRenderer.invoke('copy-mod-file', sourcePath, modsPath),
|
copyModFile: (sourcePath, modsPath) => ipcRenderer.invoke('copy-mod-file', sourcePath, modsPath),
|
||||||
onProgressUpdate: (callback) => {
|
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) => {
|
onProgressComplete: (callback) => {
|
||||||
ipcRenderer.on('progress-complete', () => callback());
|
ipcRenderer.on('progress-complete', () => callback());
|
||||||
},
|
},
|
||||||
|
onInstallationStart: (callback) => {
|
||||||
|
ipcRenderer.on('installation-start', () => callback());
|
||||||
|
},
|
||||||
|
onInstallationEnd: (callback) => {
|
||||||
|
ipcRenderer.on('installation-end', () => callback());
|
||||||
|
},
|
||||||
getUserId: () => ipcRenderer.invoke('get-user-id'),
|
getUserId: () => ipcRenderer.invoke('get-user-id'),
|
||||||
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
||||||
openDownloadPage: () => ipcRenderer.invoke('open-download-page'),
|
openDownloadPage: () => ipcRenderer.invoke('open-download-page'),
|
||||||
@@ -44,7 +75,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onUpdatePopup: (callback) => {
|
onUpdatePopup: (callback) => {
|
||||||
ipcRenderer.on('show-update-popup', (event, data) => callback(data));
|
ipcRenderer.on('show-update-popup', (event, data) => callback(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getGpuInfo: () => ipcRenderer.invoke('get-gpu-info'),
|
||||||
|
saveGpuPreference: (gpuPreference) => ipcRenderer.invoke('save-gpu-preference', gpuPreference),
|
||||||
|
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),
|
acceptFirstLaunchUpdate: (existingGame) => ipcRenderer.invoke('accept-first-launch-update', existingGame),
|
||||||
markAsLaunched: () => ipcRenderer.invoke('mark-as-launched'),
|
markAsLaunched: () => ipcRenderer.invoke('mark-as-launched'),
|
||||||
onFirstLaunchUpdate: (callback) => {
|
onFirstLaunchUpdate: (callback) => {
|
||||||
@@ -61,5 +101,42 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getLogDirectory: () => ipcRenderer.invoke('get-log-directory'),
|
getLogDirectory: () => ipcRenderer.invoke('get-log-directory'),
|
||||||
getRecentLogs: (maxLines) => ipcRenderer.invoke('get-recent-logs', maxLines)
|
openLogsFolder: () => ipcRenderer.invoke('open-logs-folder'),
|
||||||
|
getRecentLogs: (maxLines) => ipcRenderer.invoke('get-recent-logs', maxLines),
|
||||||
|
|
||||||
|
// UUID Management methods
|
||||||
|
getCurrentUuid: () => ipcRenderer.invoke('get-current-uuid'),
|
||||||
|
getAllUuidMappings: () => ipcRenderer.invoke('get-all-uuid-mappings'),
|
||||||
|
setUuidForUser: (username, uuid) => ipcRenderer.invoke('set-uuid-for-user', username, uuid),
|
||||||
|
generateNewUuid: () => ipcRenderer.invoke('generate-new-uuid'),
|
||||||
|
deleteUuidForUser: (username) => ipcRenderer.invoke('delete-uuid-for-user', username),
|
||||||
|
resetCurrentUserUuid: () => ipcRenderer.invoke('reset-current-user-uuid'),
|
||||||
|
|
||||||
|
// Profile API
|
||||||
|
profile: {
|
||||||
|
create: (name) => ipcRenderer.invoke('profile-create', name),
|
||||||
|
list: () => ipcRenderer.invoke('profile-list'),
|
||||||
|
getActive: () => ipcRenderer.invoke('profile-get-active'),
|
||||||
|
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));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user