mirror of
https://github.com/amiayweb/Hytale-F2P.git
synced 2026-02-26 13:31:47 -03:00
Compare commits
152 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46ed4cb2be | ||
|
|
6369fd7669 | ||
|
|
244fec0c77 | ||
|
|
ebc11de314 | ||
|
|
90448e200e | ||
|
|
e5b44341f1 | ||
|
|
ae6a7db80a | ||
|
|
48395fbff3 | ||
|
|
aae90a72e8 | ||
|
|
a6c61aef68 | ||
|
|
31653a37a7 | ||
|
|
1cb08f029a | ||
|
|
6a66ed831c | ||
|
|
78bb10588d | ||
|
|
d27663a1ce | ||
|
|
2db7d606bd | ||
|
|
39c12c0591 | ||
|
|
094bb938fc | ||
|
|
9c9b71bd4c | ||
|
|
c4bb15ce91 | ||
|
|
8719cd3138 | ||
|
|
611d436085 | ||
|
|
d5cc0868e9 | ||
|
|
da186333cb | ||
|
|
ae375f9b6e | ||
|
|
faf21b830b | ||
|
|
6f10b1390d | ||
|
|
c4a32ce1e0 | ||
|
|
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 | ||
|
|
3579d82776 | ||
|
|
e005b4293b | ||
|
|
e43897f816 | ||
|
|
3983fdb1bc | ||
|
|
2a87acfe46 | ||
|
|
a2e2d5e5fd | ||
|
|
34143d9872 | ||
|
|
08c2218cf8 | ||
|
|
032418b7f7 | ||
|
|
fc05725a43 | ||
|
|
203a56879f | ||
|
|
7a0065ea2b | ||
|
|
ac08eb50ff | ||
|
|
70fe4203ef | ||
|
|
f433120084 | ||
|
|
f4099acbed | ||
|
|
da843257c1 | ||
|
|
e4576042be | ||
|
|
1ba6b22b74 | ||
|
|
a1bc88b754 | ||
|
|
24c2371b50 | ||
|
|
4c6e1a616e | ||
|
|
b54eb4e834 | ||
|
|
a1c74e4175 | ||
|
|
260e6c1126 | ||
|
|
6eb628559b | ||
|
|
052b5dc7dc | ||
|
|
7e9b5046df | ||
|
|
204d6b21f6 | ||
|
|
740d516cfe | ||
|
|
ce052add0d | ||
|
|
d7a904c641 | ||
|
|
d5d2f60c97 | ||
|
|
61433bfeea | ||
|
|
9eb5d1759c | ||
|
|
68d697576a | ||
|
|
a8da559e93 | ||
|
|
75f9403888 | ||
|
|
b61c94d348 | ||
|
|
c0109575d6 | ||
|
|
2a024b61dd | ||
|
|
1c39e8e4c6 | ||
|
|
753bd4fd61 | ||
|
|
cefb4c5575 |
@@ -1,2 +0,0 @@
|
|||||||
CURSEFORGE_API_KEY=$1234asdxXXXXXXkQCXXXXXXXXXXASDb32
|
|
||||||
DISCORD_CLIENT_ID=561263XXXXXX
|
|
||||||
83
.github/CODE_OF_CONDUCT.md
vendored
83
.github/CODE_OF_CONDUCT.md
vendored
@@ -1,83 +0,0 @@
|
|||||||
# 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
70
.github/CONTRIBUTING.md
vendored
@@ -1,70 +0,0 @@
|
|||||||
# 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
54
.github/ISSUE_TEMPLATE/assets_contribution.yml
vendored
@@ -1,54 +0,0 @@
|
|||||||
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.
|
|
||||||
84
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
84
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,84 +0,0 @@
|
|||||||
name: Bug Report
|
|
||||||
description: Create a report to help us improve
|
|
||||||
title: "[BUG] "
|
|
||||||
labels: ["bug"]
|
|
||||||
body:
|
|
||||||
- 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 project are you running?
|
|
||||||
placeholder: "e.g. v1.2.3"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: os
|
|
||||||
attributes:
|
|
||||||
label: Operating System
|
|
||||||
description: What operating system are you using?
|
|
||||||
options:
|
|
||||||
- Windows
|
|
||||||
- macOS
|
|
||||||
- Linux
|
|
||||||
- iOS
|
|
||||||
- Android
|
|
||||||
- Other
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: browser
|
|
||||||
attributes:
|
|
||||||
label: Browser (if applicable)
|
|
||||||
description: What browser are you using?
|
|
||||||
options:
|
|
||||||
- Chrome
|
|
||||||
- Firefox
|
|
||||||
- Safari
|
|
||||||
- Edge
|
|
||||||
- Opera
|
|
||||||
- Other
|
|
||||||
- N/A
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Add any other context about the problem here.
|
|
||||||
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,42 +0,0 @@
|
|||||||
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. Ex. I'm always frustrated when [...]
|
|
||||||
placeholder: "Ex. I'm always frustrated when [...]"
|
|
||||||
|
|
||||||
- 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."
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Add any other context or screenshots about the feature request here.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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
|
|
||||||
54
.github/ISSUE_TEMPLATE/support_request.yml
vendored
54
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -1,54 +0,0 @@
|
|||||||
name: Support Request
|
|
||||||
description: Request help or support
|
|
||||||
title: "[SUPPORT] "
|
|
||||||
labels: ["support"]
|
|
||||||
body:
|
|
||||||
- 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..., I expected..., but got..."
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: Version
|
|
||||||
description: What version are you using?
|
|
||||||
placeholder: "e.g. v1.2.3"
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: platform
|
|
||||||
attributes:
|
|
||||||
label: Platform
|
|
||||||
description: What platform are you using?
|
|
||||||
options:
|
|
||||||
- Windows
|
|
||||||
- macOS
|
|
||||||
- Linux
|
|
||||||
- iOS
|
|
||||||
- Android
|
|
||||||
- Web Browser
|
|
||||||
- Other
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: logs
|
|
||||||
attributes:
|
|
||||||
label: Logs or Error Messages
|
|
||||||
description: If applicable, paste any error messages or logs here.
|
|
||||||
render: shell
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional
|
|
||||||
attributes:
|
|
||||||
label: Additional Information
|
|
||||||
description: Any other information that might help us assist you.
|
|
||||||
42
.github/ISSUE_TEMPLATE/translation_request.yml
vendored
42
.github/ISSUE_TEMPLATE/translation_request.yml
vendored
@@ -1,42 +0,0 @@
|
|||||||
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
24
.github/PULL_REQUEST_TEMPLATE/bug_fix.md
vendored
@@ -1,24 +0,0 @@
|
|||||||
## 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
16
.github/PULL_REQUEST_TEMPLATE/documentation.md
vendored
@@ -1,16 +0,0 @@
|
|||||||
## 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
26
.github/PULL_REQUEST_TEMPLATE/hotfix.md
vendored
@@ -1,26 +0,0 @@
|
|||||||
## 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
20
.github/PULL_REQUEST_TEMPLATE/localization.md
vendored
@@ -1,20 +0,0 @@
|
|||||||
## 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
25
.github/PULL_REQUEST_TEMPLATE/new_feature.md
vendored
@@ -1,25 +0,0 @@
|
|||||||
## 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
27
.github/PULL_REQUEST_TEMPLATE/refactor.md
vendored
@@ -1,27 +0,0 @@
|
|||||||
## 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
|
|
||||||
73
.github/README1.md
vendored
73
.github/README1.md
vendored
@@ -1,73 +0,0 @@
|
|||||||
# GitHub Actions
|
|
||||||
|
|
||||||
## Build and Release Workflow
|
|
||||||
|
|
||||||
The `release.yml` workflow automatically builds the launcher for all platforms.
|
|
||||||
|
|
||||||
### Triggers
|
|
||||||
|
|
||||||
| Trigger | Builds | Creates Release |
|
|
||||||
|---------|--------|-----------------|
|
|
||||||
| Push to `main` | Yes | No |
|
|
||||||
| Push tag `v*` | Yes | Yes |
|
|
||||||
| Manual dispatch | Yes | No |
|
|
||||||
|
|
||||||
### Platforms
|
|
||||||
|
|
||||||
All builds run in parallel:
|
|
||||||
|
|
||||||
- **Linux** (ubuntu-latest): AppImage, deb
|
|
||||||
- **Windows** (windows-latest): NSIS installer, portable exe
|
|
||||||
- **macOS** (macos-latest): Universal DMG (Intel + Apple Silicon)
|
|
||||||
|
|
||||||
### Creating a Release
|
|
||||||
|
|
||||||
**⚠️ 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`
|
|
||||||
3. Create and push a version tag:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag v2.0.11
|
|
||||||
git push origin v2.0.11
|
|
||||||
```
|
|
||||||
|
|
||||||
The workflow will:
|
|
||||||
1. Build all platforms in parallel
|
|
||||||
2. Upload artifacts to GitHub Release
|
|
||||||
3. Generate release notes automatically
|
|
||||||
|
|
||||||
### Build Artifacts
|
|
||||||
|
|
||||||
After each build, artifacts are available in the Actions tab for 90 days:
|
|
||||||
|
|
||||||
- `linux-builds`: `.AppImage`, `.deb`
|
|
||||||
- `windows-builds`: `.exe`
|
|
||||||
- `macos-builds`: `.dmg`, `.zip`, `latest-mac.yml`
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
Build locally for your platform:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build:linux
|
|
||||||
npm run build:win
|
|
||||||
npm run build:mac
|
|
||||||
```
|
|
||||||
|
|
||||||
Or build all platforms (requires appropriate OS):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build:all
|
|
||||||
```
|
|
||||||
55
.github/SECURITY.md
vendored
55
.github/SECURITY.md
vendored
@@ -1,55 +0,0 @@
|
|||||||
# 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.
|
|
||||||
148
.github/workflows/release.yml
vendored
148
.github/workflows/release.yml
vendored
@@ -1,148 +0,0 @@
|
|||||||
name: Build and Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- release
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-linux:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
# FIX Install bsdtar for Pacman builds
|
|
||||||
- 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: Create .env file
|
|
||||||
env:
|
|
||||||
CF_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
|
|
||||||
DISCORD_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
|
||||||
run: |
|
|
||||||
echo "CURSEFORGE_API_KEY=$CF_KEY" > .env
|
|
||||||
echo "DISCORD_CLIENT_ID=$DISCORD_ID" >> .env
|
|
||||||
|
|
||||||
- name: Build Linux Packages
|
|
||||||
run: |
|
|
||||||
npx electron-builder --linux --x64 --arm64 --publish never
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: linux-builds
|
|
||||||
path: |
|
|
||||||
dist/*.AppImage
|
|
||||||
dist/*.AppImage.blockmap
|
|
||||||
dist/*.deb
|
|
||||||
dist/*.rpm
|
|
||||||
dist/*.pacman
|
|
||||||
dist/latest-linux.yml
|
|
||||||
|
|
||||||
build-windows:
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'npm'
|
|
||||||
- run: npm ci
|
|
||||||
|
|
||||||
- name: Create .env file
|
|
||||||
env:
|
|
||||||
CF_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
|
|
||||||
DISCORD_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
|
||||||
run: |
|
|
||||||
echo "CURSEFORGE_API_KEY=$CF_KEY" > .env
|
|
||||||
echo "DISCORD_CLIENT_ID=$DISCORD_ID" >> .env
|
|
||||||
|
|
||||||
- name: Build Windows Packages
|
|
||||||
run: npx electron-builder --win --publish never
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: windows-builds
|
|
||||||
path: |
|
|
||||||
dist/*.exe
|
|
||||||
dist/*.exe.blockmap
|
|
||||||
dist/latest.yml
|
|
||||||
|
|
||||||
build-macos:
|
|
||||||
runs-on: macos-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'npm'
|
|
||||||
- run: npm ci
|
|
||||||
|
|
||||||
- name: Create .env file
|
|
||||||
env:
|
|
||||||
CF_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
|
|
||||||
DISCORD_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
|
||||||
run: |
|
|
||||||
echo "CURSEFORGE_API_KEY=$CF_KEY" > .env
|
|
||||||
echo "DISCORD_CLIENT_ID=$DISCORD_ID" >> .env
|
|
||||||
|
|
||||||
- name: Build Windows Packages
|
|
||||||
run: npx electron-builder --mac --publish never
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: macos-builds
|
|
||||||
path: |
|
|
||||||
dist/*.dmg
|
|
||||||
dist/*.zip
|
|
||||||
dist/latest-mac.yml
|
|
||||||
|
|
||||||
release:
|
|
||||||
needs: [build-linux, build-windows, build-macos]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: |
|
|
||||||
startsWith(github.ref, 'refs/tags/v') ||
|
|
||||||
github.ref == 'refs/heads/release' ||
|
|
||||||
github.event_name == 'workflow_dispatch'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# FIX: './package.json' Module Not Found in `Get version` step
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Display structure of downloaded files
|
|
||||||
run: ls -R artifacts
|
|
||||||
|
|
||||||
- name: Get version from package.json
|
|
||||||
id: pkg_version
|
|
||||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
# 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: |
|
|
||||||
artifacts/linux-builds/**/*
|
|
||||||
artifacts/windows-builds/**/*
|
|
||||||
artifacts/macos-builds/**/*
|
|
||||||
generate_release_notes: true
|
|
||||||
draft: true
|
|
||||||
# DYNAMIC FLAGS: Mark as pre-release ONLY IF it's NOT a tag (meaning it's a branch push)
|
|
||||||
prerelease: ${{ github.ref_type != 'tag' }}
|
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,14 +0,0 @@
|
|||||||
dist/*
|
|
||||||
node_modules/*
|
|
||||||
bun.lock
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
src/
|
|
||||||
pkg/
|
|
||||||
|
|
||||||
# Package files
|
|
||||||
*.tar.zst
|
|
||||||
*.zst.DS_Store
|
|
||||||
*.zst
|
|
||||||
bun.lockb
|
|
||||||
.env
|
|
||||||
BIN
GUI/icon.png
BIN
GUI/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB |
816
GUI/index.html
816
GUI/index.html
@@ -1,816 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Hytale F2P Launcher</title>
|
|
||||||
<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
|
|
||||||
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>
|
|
||||||
|
|
||||||
<body class="bg-black text-white overflow-hidden font-sans select-none" tabindex="-1">
|
|
||||||
<div class="absolute inset-0 z-0">
|
|
||||||
<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-[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 class="flex w-full h-screen relative z-10">
|
|
||||||
<nav class="sidebar">
|
|
||||||
<div class="sidebar-logo">
|
|
||||||
<img src="./icon.png" alt="Hytale Logo" />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-nav">
|
|
||||||
<div class="nav-item active" data-page="play">
|
|
||||||
<i class="fas fa-play"></i>
|
|
||||||
<span class="nav-tooltip" data-i18n="nav.play">Play</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item" data-page="mods">
|
|
||||||
<i class="fas fa-box"></i>
|
|
||||||
<span class="nav-tooltip" data-i18n="nav.mods">Mods</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item" data-page="news">
|
|
||||||
<i class="fas fa-newspaper"></i>
|
|
||||||
<span class="nav-tooltip" data-i18n="nav.news">News</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item" data-page="chat">
|
|
||||||
<i class="fas fa-comments"></i>
|
|
||||||
<span class="nav-tooltip" data-i18n="nav.chat">Players Chat</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item" data-page="settings">
|
|
||||||
<i class="fas fa-cog"></i>
|
|
||||||
<span class="nav-tooltip" data-i18n="nav.settings">Settings</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item logs-nav-item" data-page="logs" id="openLogsBtn" onclick="openLogs()">
|
|
||||||
<i class="fas fa-terminal"></i>
|
|
||||||
<span class="nav-tooltip">Logs</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="main-content">
|
|
||||||
<header class="header">
|
|
||||||
<div id="playersOnlineCounter" class="players-counter">
|
|
||||||
<i class="fas fa-users"></i>
|
|
||||||
<span class="counter-label" data-i18n="header.playersLabel">Players:</span>
|
|
||||||
<span id="onlineCount" class="counter-value">0</span>
|
|
||||||
</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">
|
|
||||||
<button class="control-btn minimize" onclick="window.electronAPI?.minimizeWindow()">
|
|
||||||
<i class="fas fa-minus"></i>
|
|
||||||
</button>
|
|
||||||
<button class="control-btn maximize" onclick="toggleMaximize()">
|
|
||||||
<i class="fas fa-square"></i>
|
|
||||||
</button>
|
|
||||||
<button class="control-btn close" onclick="window.electronAPI?.closeWindow()">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="game-title-section">
|
|
||||||
<h1 class="game-title">
|
|
||||||
HY<span class="title-accent">TALE</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content-pages">
|
|
||||||
<div id="install-page" class="page install-page">
|
|
||||||
<div class="install-content">
|
|
||||||
<div class="install-header">
|
|
||||||
<h1 class="install-title">
|
|
||||||
HY<span class="title-accent">TALE</span>
|
|
||||||
</h1>
|
|
||||||
<p class="install-subtitle" data-i18n="install.title">FREE TO PLAY LAUNCHER</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="install-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" data-i18n="install.playerName">Player Name</label>
|
|
||||||
<input type="text" id="installPlayerName"
|
|
||||||
data-i18n-placeholder="install.playerNamePlaceholder" class="form-input"
|
|
||||||
value="Player" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="checkbox-group">
|
|
||||||
<input type="checkbox" id="installCustomCheck" class="custom-checkbox">
|
|
||||||
<span class="checkbox-label" data-i18n="install.customInstallation">Custom
|
|
||||||
Installation</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div id="installCustomOptions" class="custom-options">
|
|
||||||
<div class="form-subgroup">
|
|
||||||
<label class="form-label" data-i18n="install.installationFolder">Installation
|
|
||||||
Folder</label>
|
|
||||||
<div class="input-with-button">
|
|
||||||
<input type="text" id="installPath"
|
|
||||||
data-i18n-placeholder="install.pathPlaceholder" class="form-input"
|
|
||||||
readonly />
|
|
||||||
<button onclick="browseInstallPath()" class="browse-btn">
|
|
||||||
<i class="fas fa-folder-open"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="installBtn" class="install-button" onclick="installGame()">
|
|
||||||
<i class="fas fa-download mr-2"></i>
|
|
||||||
<span id="installText" data-i18n="install.installButton">INSTALL HYTALE</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="launcher-container" class="launcher-container" style="display: none;">
|
|
||||||
<div id="play-page" class="page active">
|
|
||||||
<div class="play-section">
|
|
||||||
<div class="play-content">
|
|
||||||
<div class="play-header">
|
|
||||||
<h2 class="play-title">
|
|
||||||
<i class="fas fa-play-circle mr-2"></i>
|
|
||||||
<span data-i18n="play.ready">READY TO PLAY</span>
|
|
||||||
</h2>
|
|
||||||
<p class="play-subtitle" data-i18n="play.subtitle">Launch Hytale and enter the
|
|
||||||
adventure</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="homePlayBtn" class="home-play-button" onclick="launch()">
|
|
||||||
<i class="fas fa-play"></i>
|
|
||||||
<span data-i18n="play.playButton">PLAY HYTALE</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="news-section">
|
|
||||||
<div class="news-header">
|
|
||||||
<h2 class="news-title">
|
|
||||||
<i class="fas fa-star mr-2"></i>
|
|
||||||
<span data-i18n="play.latestNews">LATEST NEWS</span>
|
|
||||||
</h2>
|
|
||||||
<button class="view-all-btn" onclick="navigateToPage('news')">
|
|
||||||
<span data-i18n="play.viewAll">VIEW ALL</span> <i
|
|
||||||
class="fas fa-arrow-right ml-1"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="newsGrid" class="news-grid-horizontal"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="mods-page" class="page">
|
|
||||||
<div class="mods-header">
|
|
||||||
<div class="mods-search-container">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
<input type="text" id="modsSearch" data-i18n-placeholder="mods.searchPlaceholder"
|
|
||||||
class="mods-search" />
|
|
||||||
</div>
|
|
||||||
<div class="mods-actions">
|
|
||||||
<button id="myModsBtn" class="mods-btn-primary">
|
|
||||||
<i class="fas fa-box"></i>
|
|
||||||
<span data-i18n="mods.myMods">MY MODS</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="browseModsList" class="mods-browse-container">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mods-pagination">
|
|
||||||
<button id="prevPage" class="pagination-btn">
|
|
||||||
<i class="fas fa-chevron-left"></i>
|
|
||||||
<span data-i18n="mods.previous">PREVIOUS</span>
|
|
||||||
</button>
|
|
||||||
<span class="pagination-info">
|
|
||||||
<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>
|
|
||||||
<button id="nextPage" class="pagination-btn">
|
|
||||||
<span data-i18n="mods.next">NEXT</span>
|
|
||||||
<i class="fas fa-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="news-page" class="page">
|
|
||||||
<div class="news-header">
|
|
||||||
<h2 class="news-title">
|
|
||||||
<i class="fas fa-newspaper mr-2"></i>
|
|
||||||
<span data-i18n="news.title">ALL NEWS</span>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div id="allNewsGrid" class="news-grid-full"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="chat-page" class="page">
|
|
||||||
<div class="chat-container">
|
|
||||||
<div class="chat-header">
|
|
||||||
<h2 class="chat-title">
|
|
||||||
<i class="fas fa-comments mr-2"></i>
|
|
||||||
<span data-i18n="chat.title">PLAYERS CHAT</span>
|
|
||||||
</h2>
|
|
||||||
<div class="chat-header-actions">
|
|
||||||
<button id="chatColorBtn" class="chat-color-btn">
|
|
||||||
<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 class="chat-body">
|
|
||||||
<div id="chatMessages" class="chat-messages">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chat-footer">
|
|
||||||
<div class="chat-input-container">
|
|
||||||
<textarea id="chatInput" class="chat-input"
|
|
||||||
data-i18n-placeholder="chat.inputPlaceholder" rows="1"
|
|
||||||
maxlength="500"></textarea>
|
|
||||||
<button id="chatSendBtn" class="chat-send-btn">
|
|
||||||
<i class="fas fa-paper-plane"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="chat-footer-info">
|
|
||||||
<span class="chat-char-counter" id="chatCharCounter">0/500</span>
|
|
||||||
<span class="chat-warning-text">
|
|
||||||
<i class="fas fa-shield-alt"></i>
|
|
||||||
<span data-i18n="chat.secureChat">Secure chat - Links are censored</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="settings-page" class="page">
|
|
||||||
<div class="settings-container">
|
|
||||||
<div class="settings-header">
|
|
||||||
<h2 class="settings-title">
|
|
||||||
<i class="fas fa-cog mr-2"></i>
|
|
||||||
<span data-i18n="settings.title">SETTINGS</span>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-content">
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3 class="settings-section-title">
|
|
||||||
<i class="fas fa-gamepad"></i>
|
|
||||||
<span data-i18n="settings.game">Game Options</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="settings-option">
|
|
||||||
<div class="settings-input-group">
|
|
||||||
<label class="settings-input-label" data-i18n="settings.playerName">Player
|
|
||||||
Name</label>
|
|
||||||
<input type="text" id="settingsPlayerName" class="settings-input"
|
|
||||||
data-i18n-placeholder="settings.playerNamePlaceholder" maxlength="16" />
|
|
||||||
<p class="settings-hint">
|
|
||||||
<i class="fas fa-user"></i>
|
|
||||||
<span data-i18n="settings.playerNameHint">This name will be used in-game
|
|
||||||
(1-16 characters)</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-option">
|
|
||||||
<div class="settings-button-group">
|
|
||||||
<button id="openGameLocationBtn" class="settings-action-btn"
|
|
||||||
onclick="openGameLocation()">
|
|
||||||
<i class="fas fa-folder-open"></i>
|
|
||||||
<div class="btn-content">
|
|
||||||
<div class="btn-title" data-i18n="settings.openGameLocation">Open
|
|
||||||
Game Location</div>
|
|
||||||
<div class="btn-description"
|
|
||||||
data-i18n="settings.openGameLocationDesc">Open the game
|
|
||||||
installation folder</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</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 class="settings-input-group">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
|
|
||||||
<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 id="logs-page" class="page">
|
|
||||||
<div class="logs-container">
|
|
||||||
<div class="logs-header">
|
|
||||||
<h2 class="logs-title">
|
|
||||||
<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>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="myModsModal" class="mods-modal">
|
|
||||||
<div class="mods-modal-content">
|
|
||||||
<div class="mods-modal-header">
|
|
||||||
<h2 class="mods-modal-title">
|
|
||||||
<i class="fas fa-box mr-2"></i>
|
|
||||||
<span data-i18n="mods.modalTitle">MY MODS</span>
|
|
||||||
</h2>
|
|
||||||
<button id="closeMyModsModal" class="mods-modal-close">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mods-modal-body">
|
|
||||||
<div id="installedModsList" class="installed-mods-list">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="progressOverlay" class="progress-overlay" style="display: none;">
|
|
||||||
<div class="progress-content">
|
|
||||||
<div class="progress-info">
|
|
||||||
<span id="progressText" data-i18n="progress.initializing">Initializing...</span>
|
|
||||||
<span id="progressPercent">0%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress-bar-container">
|
|
||||||
<div id="progressBarFill" class="progress-bar-fill"></div>
|
|
||||||
</div>
|
|
||||||
<div class="progress-details">
|
|
||||||
<span id="progressSpeed"></span>
|
|
||||||
<span id="progressSize"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Installation effects overlay -->
|
|
||||||
<div id="installationEffects" class="installation-effects" style="display: none;">
|
|
||||||
<div class="space-effects">
|
|
||||||
<div class="warp-line"></div>
|
|
||||||
<div class="warp-line"></div>
|
|
||||||
<div class="warp-line"></div>
|
|
||||||
<div class="warp-line"></div>
|
|
||||||
<div class="warp-line"></div>
|
|
||||||
<div class="warp-line"></div>
|
|
||||||
<div class="warp-line"></div>
|
|
||||||
<div class="warp-line"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="chatUsernameModal" class="chat-username-modal" style="display: none;">
|
|
||||||
<div class="chat-username-modal-content">
|
|
||||||
<div class="chat-username-modal-header">
|
|
||||||
<h2 class="chat-username-modal-title">
|
|
||||||
<i class="fas fa-comments mr-2"></i>
|
|
||||||
<span data-i18n="chat.joinChat">Join Chat</span>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="chat-username-modal-body">
|
|
||||||
<p class="chat-username-modal-description" data-i18n="chat.chooseUsername">
|
|
||||||
Choose a username to join the Players Chat
|
|
||||||
</p>
|
|
||||||
<div class="chat-username-input-group">
|
|
||||||
<label for="chatUsernameInput" class="chat-username-label"
|
|
||||||
data-i18n="chat.username">Username</label>
|
|
||||||
<input type="text" id="chatUsernameInput" class="chat-username-input"
|
|
||||||
data-i18n-placeholder="chat.usernamePlaceholder" maxlength="20" autocomplete="off" />
|
|
||||||
<span class="chat-username-hint" data-i18n="chat.usernameHint">3-20 characters, letters, numbers, -
|
|
||||||
and _ only</span>
|
|
||||||
<span id="chatUsernameError" class="chat-username-error"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="chat-username-modal-footer">
|
|
||||||
<button id="chatUsernameCancel" class="chat-username-btn-cancel">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
<span data-i18n="common.cancel">Cancel</span>
|
|
||||||
</button>
|
|
||||||
<button id="chatUsernameSubmit" class="chat-username-btn-submit">
|
|
||||||
<i class="fas fa-check"></i>
|
|
||||||
<span data-i18n="chat.joinButton">Join Chat</span>
|
|
||||||
</button>
|
|
||||||
</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">
|
|
||||||
<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> & <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>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/crimera" target="_blank"
|
|
||||||
class="text-blue-400 hover:text-blue-300 transition-colors">@crimera</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>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script type="module" src="js/script.js"></script> <!-- Discord Notification -->
|
|
||||||
<div id="discordNotification" class="discord-notification">
|
|
||||||
<div class="notification-content">
|
|
||||||
<i class="fab fa-discord"></i>
|
|
||||||
<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')">
|
|
||||||
<span data-i18n="discord.joinButton">Join Discord</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button class="notification-close" onclick="closeDiscordNotification()">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</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/update.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
|
|
||||||
</html>
|
|
||||||
500
GUI/js/chat.js
500
GUI/js/chat.js
@@ -1,500 +0,0 @@
|
|||||||
|
|
||||||
let socket = null;
|
|
||||||
let isAuthenticated = false;
|
|
||||||
let messageQueue = [];
|
|
||||||
let chatUsername = '';
|
|
||||||
let userColor = '#3498db';
|
|
||||||
let userBadge = null;
|
|
||||||
const SOCKET_URL = 'https://chat.hytalef2p.com';
|
|
||||||
const MAX_MESSAGE_LENGTH = 500;
|
|
||||||
|
|
||||||
async function getOrCreatePlayerId() {
|
|
||||||
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initChat() {
|
|
||||||
if (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() === '') {
|
|
||||||
showUsernameModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupChatUI();
|
|
||||||
setupColorSelector();
|
|
||||||
await connectToChat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showUsernameModal() {
|
|
||||||
const modal = document.getElementById('chatUsernameModal');
|
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
|
|
||||||
const input = document.getElementById('chatUsernameInput');
|
|
||||||
if (input) {
|
|
||||||
setTimeout(() => input.focus(), 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideUsernameModal() {
|
|
||||||
const modal = document.getElementById('chatUsernameModal');
|
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitChatUsername() {
|
|
||||||
const input = document.getElementById('chatUsernameInput');
|
|
||||||
const errorMsg = document.getElementById('chatUsernameError');
|
|
||||||
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
const username = input.value.trim();
|
|
||||||
|
|
||||||
if (username.length === 0) {
|
|
||||||
if (errorMsg) errorMsg.textContent = 'Username cannot be empty';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (username.length < 3) {
|
|
||||||
if (errorMsg) errorMsg.textContent = 'Username must be at least 3 characters';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (username.length > 20) {
|
|
||||||
if (errorMsg) errorMsg.textContent = 'Username must be 20 characters or less';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
|
||||||
if (errorMsg) errorMsg.textContent = 'Username can only contain letters, numbers, - and _';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
chatUsername = username;
|
|
||||||
if (window.electronAPI?.saveChatUsername) {
|
|
||||||
await window.electronAPI.saveChatUsername(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
hideUsernameModal();
|
|
||||||
|
|
||||||
setupChatUI();
|
|
||||||
await connectToChat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupChatUI() {
|
|
||||||
const sendBtn = document.getElementById('chatSendBtn');
|
|
||||||
const chatInput = document.getElementById('chatInput');
|
|
||||||
const chatMessages = document.getElementById('chatMessages');
|
|
||||||
|
|
||||||
if (!sendBtn || !chatInput || !chatMessages) {
|
|
||||||
console.warn('Chat UI elements not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendBtn.addEventListener('click', () => {
|
|
||||||
sendMessage();
|
|
||||||
});
|
|
||||||
|
|
||||||
chatInput.addEventListener('keypress', (e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
sendMessage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
chatInput.addEventListener('input', () => {
|
|
||||||
if (chatInput.value.length > MAX_MESSAGE_LENGTH) {
|
|
||||||
chatInput.value = chatInput.value.substring(0, MAX_MESSAGE_LENGTH);
|
|
||||||
}
|
|
||||||
updateCharCounter();
|
|
||||||
});
|
|
||||||
|
|
||||||
updateCharCounter();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectToChat() {
|
|
||||||
try {
|
|
||||||
if (!window.io) {
|
|
||||||
await loadSocketIO();
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = await window.electronAPI?.getUserId();
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
console.error('User ID not available');
|
|
||||||
addSystemMessage('Error: Could not connect to chat');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chatUsername || chatUsername.trim() === '') {
|
|
||||||
console.error('Chat username not set');
|
|
||||||
addSystemMessage('Error: Username not set');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket = io(SOCKET_URL, {
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
reconnection: true,
|
|
||||||
reconnectionAttempts: 5,
|
|
||||||
reconnectionDelay: 1000
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', async () => {
|
|
||||||
console.log('Connected to chat server');
|
|
||||||
|
|
||||||
const uuid = await window.electronAPI?.getCurrentUuid();
|
|
||||||
|
|
||||||
socket.emit('authenticate', {
|
|
||||||
username: chatUsername,
|
|
||||||
userId,
|
|
||||||
uuid: uuid,
|
|
||||||
userColor: userColor
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('authenticated', (data) => {
|
|
||||||
isAuthenticated = true;
|
|
||||||
userBadge = data.badge;
|
|
||||||
addSystemMessage(`Connected as ${data.username}`);
|
|
||||||
|
|
||||||
while (messageQueue.length > 0) {
|
|
||||||
const msg = messageQueue.shift();
|
|
||||||
socket.emit('send_message', { message: msg });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('message', (data) => {
|
|
||||||
if (data.type === 'system') {
|
|
||||||
addSystemMessage(data.message);
|
|
||||||
} else if (data.type === 'user') {
|
|
||||||
addUserMessage(data.username, data.message, data.timestamp, data.userColor, data.badge);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('users_update', (data) => {
|
|
||||||
updateOnlineCount(data.count);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (data) => {
|
|
||||||
addSystemMessage(`Error: ${data.message}`, 'error');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('clear_chat', (data) => {
|
|
||||||
clearAllMessages();
|
|
||||||
addSystemMessage(data.message || 'Chat cleared by server', 'warning');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
isAuthenticated = false;
|
|
||||||
console.log('Disconnected from chat server');
|
|
||||||
addSystemMessage('Disconnected from chat', 'error');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect_error', (error) => {
|
|
||||||
console.error('Connection error:', error);
|
|
||||||
addSystemMessage('Connection error. Retrying...', 'error');
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error connecting to chat:', error);
|
|
||||||
addSystemMessage('Failed to connect to chat server', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSocketIO() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://cdn.socket.io/4.6.1/socket.io.min.js';
|
|
||||||
script.onload = resolve;
|
|
||||||
script.onerror = reject;
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendMessage() {
|
|
||||||
const chatInput = document.getElementById('chatInput');
|
|
||||||
const message = chatInput.value.trim();
|
|
||||||
|
|
||||||
if (!message || message.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.length > MAX_MESSAGE_LENGTH) {
|
|
||||||
addSystemMessage(`Message too long (max ${MAX_MESSAGE_LENGTH} characters)`, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!socket || !isAuthenticated) {
|
|
||||||
messageQueue.push(message);
|
|
||||||
addSystemMessage('Connecting... Your message will be sent soon.', 'warning');
|
|
||||||
chatInput.value = '';
|
|
||||||
updateCharCounter();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit('send_message', { message });
|
|
||||||
|
|
||||||
chatInput.value = '';
|
|
||||||
updateCharCounter();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addUserMessage(username, message, timestamp, userColor = '#3498db', badge = null) {
|
|
||||||
const chatMessages = document.getElementById('chatMessages');
|
|
||||||
if (!chatMessages) return;
|
|
||||||
|
|
||||||
const messageDiv = document.createElement('div');
|
|
||||||
messageDiv.className = 'chat-message user-message';
|
|
||||||
|
|
||||||
const time = new Date(timestamp).toLocaleTimeString('en-US', {
|
|
||||||
hour: '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 = `
|
|
||||||
<div class="message-header">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div class="message-content">${message}</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const usernameElement = messageDiv.querySelector('.message-username');
|
|
||||||
if (usernameElement) {
|
|
||||||
applyUserColorStyle(usernameElement, userColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
chatMessages.appendChild(messageDiv);
|
|
||||||
scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSystemMessage(message, type = 'info') {
|
|
||||||
const chatMessages = document.getElementById('chatMessages');
|
|
||||||
if (!chatMessages) return;
|
|
||||||
|
|
||||||
const messageDiv = document.createElement('div');
|
|
||||||
messageDiv.className = `chat-message system-message system-${type}`;
|
|
||||||
messageDiv.innerHTML = `
|
|
||||||
<div class="message-content">
|
|
||||||
<i class="fas fa-info-circle"></i> ${escapeHtml(message)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
chatMessages.appendChild(messageDiv);
|
|
||||||
scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOnlineCount(count) {
|
|
||||||
const onlineCountElement = document.getElementById('chatOnlineCount');
|
|
||||||
if (onlineCountElement) {
|
|
||||||
onlineCountElement.textContent = count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCharCounter() {
|
|
||||||
const chatInput = document.getElementById('chatInput');
|
|
||||||
const charCounter = document.getElementById('chatCharCounter');
|
|
||||||
|
|
||||||
if (chatInput && charCounter) {
|
|
||||||
const length = chatInput.value.length;
|
|
||||||
charCounter.textContent = `${length}/${MAX_MESSAGE_LENGTH}`;
|
|
||||||
|
|
||||||
if (length > MAX_MESSAGE_LENGTH * 0.9) {
|
|
||||||
charCounter.classList.add('warning');
|
|
||||||
} else {
|
|
||||||
charCounter.classList.remove('warning');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
|
||||||
const chatMessages = document.getElementById('chatMessages');
|
|
||||||
if (chatMessages) {
|
|
||||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAllMessages() {
|
|
||||||
const chatMessages = document.getElementById('chatMessages');
|
|
||||||
if (chatMessages) {
|
|
||||||
chatMessages.innerHTML = '';
|
|
||||||
console.log('Chat cleared');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
if (socket && socket.connected) {
|
|
||||||
socket.disconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const usernameSubmitBtn = document.getElementById('chatUsernameSubmit');
|
|
||||||
const usernameCancelBtn = document.getElementById('chatUsernameCancel');
|
|
||||||
const usernameInput = document.getElementById('chatUsernameInput');
|
|
||||||
|
|
||||||
if (usernameSubmitBtn) {
|
|
||||||
usernameSubmitBtn.addEventListener('click', submitChatUsername);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usernameCancelBtn) {
|
|
||||||
usernameCancelBtn.addEventListener('click', () => {
|
|
||||||
hideUsernameModal();
|
|
||||||
const playNavItem = document.querySelector('[data-page="play"]');
|
|
||||||
if (playNavItem) playNavItem.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usernameInput) {
|
|
||||||
usernameInput.addEventListener('keypress', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
submitChatUsername();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const chatNavItem = document.querySelector('[data-page="chat"]');
|
|
||||||
if (chatNavItem) {
|
|
||||||
chatNavItem.addEventListener('click', () => {
|
|
||||||
if (!socket) {
|
|
||||||
initChat();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 = {
|
|
||||||
send: sendMessage,
|
|
||||||
disconnect: () => socket?.disconnect()
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// Minimal i18n system - optimized async loading
|
|
||||||
const i18n = (() => {
|
|
||||||
let currentLang = 'en';
|
|
||||||
let translations = {};
|
|
||||||
const availableLanguages = [
|
|
||||||
{ code: 'en', name: 'English' },
|
|
||||||
{ code: 'es', name: 'Español' },
|
|
||||||
{ code: 'pt-BR', name: 'Português (Brasil)' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
let isDownloading = false;
|
|
||||||
|
|
||||||
let installPage;
|
|
||||||
let installBtn;
|
|
||||||
let installText;
|
|
||||||
let installPlayerName;
|
|
||||||
let installCustomCheck;
|
|
||||||
let installCustomOptions;
|
|
||||||
let installPathInput;
|
|
||||||
|
|
||||||
export function setupInstallation() {
|
|
||||||
installPage = document.getElementById('install-page');
|
|
||||||
installBtn = document.getElementById('installBtn');
|
|
||||||
installText = document.getElementById('installText');
|
|
||||||
installPlayerName = document.getElementById('installPlayerName');
|
|
||||||
installCustomCheck = document.getElementById('installCustomCheck');
|
|
||||||
installCustomOptions = document.getElementById('installCustomOptions');
|
|
||||||
installPathInput = document.getElementById('installPath');
|
|
||||||
|
|
||||||
if (installCustomCheck && installCustomOptions) {
|
|
||||||
installCustomCheck.addEventListener('change', (e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
installCustomOptions.classList.add('show');
|
|
||||||
} else {
|
|
||||||
installCustomOptions.classList.remove('show');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (installPlayerName) {
|
|
||||||
installPlayerName.addEventListener('change', savePlayerName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.onProgressUpdate) {
|
|
||||||
window.electronAPI.onProgressUpdate((data) => {
|
|
||||||
if (!isDownloading) return;
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.updateProgress(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup installation effects listeners
|
|
||||||
if (window.electronAPI && window.electronAPI.onInstallationStart) {
|
|
||||||
window.electronAPI.onInstallationStart(() => {
|
|
||||||
showInstallationEffects();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.onInstallationEnd) {
|
|
||||||
window.electronAPI.onInstallationEnd(() => {
|
|
||||||
hideInstallationEffects();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installGame() {
|
|
||||||
if (isDownloading || (installBtn && installBtn.disabled)) return;
|
|
||||||
|
|
||||||
const playerName = (installPlayerName ? installPlayerName.value.trim() : '') || 'Player';
|
|
||||||
const installPath = installPathInput ? installPathInput.value.trim() : '';
|
|
||||||
|
|
||||||
if (window.LauncherUI) window.LauncherUI.showProgress();
|
|
||||||
isDownloading = true;
|
|
||||||
if (installBtn) {
|
|
||||||
installBtn.disabled = true;
|
|
||||||
installText.textContent = window.i18n ? window.i18n.t('install.installing') : 'INSTALLING...';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.installGame) {
|
|
||||||
const result = await window.electronAPI.installGame(playerName, '', installPath);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const successMsg = window.i18n ? window.i18n.t('progress.installationComplete') : 'Installation completed successfully!';
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.updateProgress({ message: successMsg });
|
|
||||||
setTimeout(() => {
|
|
||||||
window.LauncherUI.hideProgress();
|
|
||||||
window.LauncherUI.showLauncherOrInstall(true);
|
|
||||||
const playerNameInput = document.getElementById('playerName');
|
|
||||||
if (playerNameInput) playerNameInput.value = playerName;
|
|
||||||
resetInstallButton();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Installation failed');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
simulateInstallation(playerName);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = window.i18n ? window.i18n.t('progress.installationFailed').replace('{error}', error.message) : `Installation failed: ${error.message}`;
|
|
||||||
|
|
||||||
// Hide installation effects on error
|
|
||||||
if (window.hideInstallationEffects) {
|
|
||||||
window.hideInstallationEffects();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset button state on error
|
|
||||||
resetInstallButton();
|
|
||||||
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.updateProgress({ message: errorMsg });
|
|
||||||
// Don't hide progress bar, just update the message
|
|
||||||
// User can see the error and close it manually
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function simulateInstallation(playerName) {
|
|
||||||
let progress = 0;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
progress += Math.random() * 3;
|
|
||||||
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) {
|
|
||||||
window.LauncherUI.updateProgress({
|
|
||||||
percent: progress,
|
|
||||||
message: progress < 100 ? installingMsg : completeMsg,
|
|
||||||
speed: 1024 * 1024 * (5 + Math.random() * 10),
|
|
||||||
downloaded: progress * 1024 * 1024 * 20,
|
|
||||||
total: 1024 * 1024 * 2000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress >= 100) {
|
|
||||||
clearInterval(interval);
|
|
||||||
const successMsg = window.i18n ? window.i18n.t('progress.installationComplete') : 'Installation completed successfully!';
|
|
||||||
setTimeout(() => {
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.updateProgress({ message: successMsg });
|
|
||||||
setTimeout(() => {
|
|
||||||
window.LauncherUI.hideProgress();
|
|
||||||
window.LauncherUI.showLauncherOrInstall(true);
|
|
||||||
const playerNameInput = document.getElementById('playerName');
|
|
||||||
if (playerNameInput) playerNameInput.value = playerName;
|
|
||||||
resetInstallButton();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetInstallButton() {
|
|
||||||
isDownloading = false;
|
|
||||||
if (installBtn) {
|
|
||||||
installBtn.disabled = false;
|
|
||||||
installText.textContent = 'INSTALL HYTALE';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function browseInstallPath() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.selectInstallPath) {
|
|
||||||
const result = await window.electronAPI.selectInstallPath();
|
|
||||||
if (result && installPathInput) {
|
|
||||||
installPathInput.value = result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error browsing install path:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePlayerName() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.saveSettings) {
|
|
||||||
const playerName = (installPlayerName ? installPlayerName.value.trim() : '') || 'Player';
|
|
||||||
await window.electronAPI.saveSettings({ playerName });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving player name:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkGameStatusAndShowInterface() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.isGameInstalled) {
|
|
||||||
const installed = await window.electronAPI.isGameInstalled();
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.showLauncherOrInstall(installed);
|
|
||||||
}
|
|
||||||
if (installed) {
|
|
||||||
await loadPlayerSettings();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.showLauncherOrInstall(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking game status:', error);
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.showLauncherOrInstall(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPlayerSettings() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.loadSettings) {
|
|
||||||
const settings = await window.electronAPI.loadSettings();
|
|
||||||
if (settings) {
|
|
||||||
const playerNameInput = document.getElementById('playerName');
|
|
||||||
const javaPathInput = document.getElementById('javaPath');
|
|
||||||
if (settings.playerName && playerNameInput) {
|
|
||||||
playerNameInput.value = settings.playerName;
|
|
||||||
}
|
|
||||||
if (settings.javaPath && javaPathInput) {
|
|
||||||
javaPathInput.value = settings.javaPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading settings:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.installGame = installGame;
|
|
||||||
window.browseInstallPath = browseInstallPath;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
setupInstallation();
|
|
||||||
await checkGameStatusAndShowInterface();
|
|
||||||
});
|
|
||||||
window.browseInstallPath = browseInstallPath;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
setupInstallation();
|
|
||||||
await checkGameStatusAndShowInterface();
|
|
||||||
});
|
|
||||||
@@ -1,647 +0,0 @@
|
|||||||
let isDownloading = false;
|
|
||||||
|
|
||||||
let playBtn;
|
|
||||||
let playText;
|
|
||||||
let homePlayBtn;
|
|
||||||
let uninstallBtn;
|
|
||||||
let playerNameInput;
|
|
||||||
let javaPathInput;
|
|
||||||
|
|
||||||
export function setupLauncher() {
|
|
||||||
playBtn = document.getElementById('playBtn');
|
|
||||||
playText = document.getElementById('playText');
|
|
||||||
homePlayBtn = document.getElementById('homePlayBtn');
|
|
||||||
uninstallBtn = document.getElementById('uninstallBtn');
|
|
||||||
playerNameInput = document.getElementById('playerName');
|
|
||||||
javaPathInput = document.getElementById('javaPath');
|
|
||||||
|
|
||||||
if (playerNameInput) {
|
|
||||||
playerNameInput.addEventListener('change', savePlayerName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (javaPathInput) {
|
|
||||||
javaPathInput.addEventListener('change', saveJavaPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.onProgressUpdate) {
|
|
||||||
window.electronAPI.onProgressUpdate((data) => {
|
|
||||||
if (!isDownloading) return;
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.updateProgress(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial Profile Load
|
|
||||||
loadProfiles();
|
|
||||||
|
|
||||||
// Close dropdown on outside click
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
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() {
|
|
||||||
if (isDownloading || (playBtn && playBtn.disabled)) return;
|
|
||||||
|
|
||||||
let playerName = 'Player';
|
|
||||||
if (window.SettingsAPI && window.SettingsAPI.getCurrentPlayerName) {
|
|
||||||
playerName = window.SettingsAPI.getCurrentPlayerName();
|
|
||||||
} else if (playerNameInput && playerNameInput.value.trim()) {
|
|
||||||
playerName = playerNameInput.value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
let javaPath = '';
|
|
||||||
if (window.SettingsAPI && 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();
|
|
||||||
isDownloading = true;
|
|
||||||
if (playBtn) {
|
|
||||||
playBtn.disabled = true;
|
|
||||||
playText.textContent = 'LAUNCHING...';
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const result = await window.electronAPI.launchGame(playerName, javaPath, '', gpuPreference);
|
|
||||||
|
|
||||||
isDownloading = false;
|
|
||||||
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.hideProgress();
|
|
||||||
}
|
|
||||||
resetPlayButton();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
if (window.electronAPI.minimizeWindow) {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.electronAPI.minimizeWindow();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Launch failed:', result.error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isDownloading = false;
|
|
||||||
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.hideProgress();
|
|
||||||
}
|
|
||||||
resetPlayButton();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
isDownloading = false;
|
|
||||||
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.hideProgress();
|
|
||||||
}
|
|
||||||
resetPlayButton();
|
|
||||||
console.error('Launch error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCustomConfirm(message, title, onConfirm, onCancel = null, confirmText, cancelText) {
|
|
||||||
// Apply defaults with i18n support
|
|
||||||
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();
|
|
||||||
const uninstallingMsg = window.i18n ? window.i18n.t('progress.uninstallingGame') : 'Uninstalling game...';
|
|
||||||
if (window.LauncherUI) window.LauncherUI.updateProgress({ message: uninstallingMsg });
|
|
||||||
if (uninstallBtn) uninstallBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.uninstallGame) {
|
|
||||||
const result = await window.electronAPI.uninstallGame();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const successMsg = window.i18n ? window.i18n.t('progress.gameUninstalled') : 'Game uninstalled successfully!';
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.updateProgress({ message: successMsg });
|
|
||||||
setTimeout(() => {
|
|
||||||
window.LauncherUI.hideProgress();
|
|
||||||
window.LauncherUI.showLauncherOrInstall(false);
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Uninstall failed');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const successMsg = window.i18n ? window.i18n.t('progress.gameUninstalled') : 'Game uninstalled successfully!';
|
|
||||||
setTimeout(() => {
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.updateProgress({ message: successMsg });
|
|
||||||
setTimeout(() => {
|
|
||||||
window.LauncherUI.hideProgress();
|
|
||||||
window.LauncherUI.showLauncherOrInstall(false);
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = window.i18n ? window.i18n.t('progress.uninstallFailed').replace('{error}', error.message) : `Uninstall failed: ${error.message}`;
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.updateProgress({ message: errorMsg });
|
|
||||||
setTimeout(() => window.LauncherUI.hideProgress(), 3000);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (uninstallBtn) uninstallBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
isDownloading = false;
|
|
||||||
if (playBtn) {
|
|
||||||
playBtn.disabled = false;
|
|
||||||
playText.textContent = window.i18n ? window.i18n.t('play.play') : 'PLAY';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePlayerName() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.saveSettings) {
|
|
||||||
const playerName = (playerNameInput ? playerNameInput.value.trim() : '') || 'Player';
|
|
||||||
await window.electronAPI.saveSettings({ playerName });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving player name:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveJavaPath() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.saveSettings) {
|
|
||||||
const javaPath = (javaPathInput ? javaPathInput.value.trim() : '') || '';
|
|
||||||
await window.electronAPI.saveSettings({ javaPath });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving Java path:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCustomJava() {
|
|
||||||
if (!customJavaOptions) return;
|
|
||||||
|
|
||||||
if (customJavaCheck && customJavaCheck.checked) {
|
|
||||||
customJavaOptions.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
customJavaOptions.style.display = 'none';
|
|
||||||
if (customJavaPath) customJavaPath.value = '';
|
|
||||||
saveCustomJavaPath('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function browseJavaPath() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.browseJavaPath) {
|
|
||||||
const result = await window.electronAPI.browseJavaPath();
|
|
||||||
if (result && result.filePaths && result.filePaths.length > 0) {
|
|
||||||
const selectedPath = result.filePaths[0];
|
|
||||||
if (customJavaPath) {
|
|
||||||
customJavaPath.value = selectedPath;
|
|
||||||
}
|
|
||||||
await saveCustomJavaPath(selectedPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error browsing Java path:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCustomJavaPath(path) {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.saveJavaPath) {
|
|
||||||
await window.electronAPI.saveJavaPath(path);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving custom Java path:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCustomJavaPath() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.loadJavaPath) {
|
|
||||||
const savedPath = await window.electronAPI.loadJavaPath();
|
|
||||||
if (savedPath && savedPath.trim()) {
|
|
||||||
if (customJavaPath) {
|
|
||||||
customJavaPath.value = savedPath;
|
|
||||||
}
|
|
||||||
if (customJavaCheck) {
|
|
||||||
customJavaCheck.checked = true;
|
|
||||||
}
|
|
||||||
if (customJavaOptions) {
|
|
||||||
customJavaOptions.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading custom Java path:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.launch = launch;
|
|
||||||
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);
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
|
|
||||||
// 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'] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
764
GUI/js/mods.js
764
GUI/js/mods.js
@@ -1,764 +0,0 @@
|
|||||||
|
|
||||||
let API_KEY = null;
|
|
||||||
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
|
|
||||||
const HYTALE_GAME_ID = 70216;
|
|
||||||
|
|
||||||
let installedMods = [];
|
|
||||||
let browseMods = [];
|
|
||||||
let searchQuery = '';
|
|
||||||
let modsPage = 0;
|
|
||||||
let modsPageSize = 20;
|
|
||||||
let modsTotalPages = 1;
|
|
||||||
|
|
||||||
export async function initModsManager() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.getEnvVar) {
|
|
||||||
API_KEY = await window.electronAPI.getEnvVar('CURSEFORGE_API_KEY');
|
|
||||||
console.log('Loaded API Key:', API_KEY ? 'Yes' : 'No');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load API Key:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
setupModsEventListeners();
|
|
||||||
await loadInstalledMods();
|
|
||||||
await loadBrowseMods();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupModsEventListeners() {
|
|
||||||
const searchInput = document.getElementById('modsSearch');
|
|
||||||
if (searchInput) {
|
|
||||||
let searchTimeout;
|
|
||||||
searchInput.addEventListener('input', (e) => {
|
|
||||||
searchQuery = e.target.value.toLowerCase().trim();
|
|
||||||
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
modsPage = 0;
|
|
||||||
loadBrowseMods();
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const myModsBtn = document.getElementById('myModsBtn');
|
|
||||||
if (myModsBtn) {
|
|
||||||
myModsBtn.addEventListener('click', openMyModsModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeModalBtn = document.getElementById('closeMyModsModal');
|
|
||||||
if (closeModalBtn) {
|
|
||||||
closeModalBtn.addEventListener('click', closeMyModsModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.getElementById('myModsModal');
|
|
||||||
if (modal) {
|
|
||||||
modal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === modal) {
|
|
||||||
closeMyModsModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevPageBtn = document.getElementById('prevPage');
|
|
||||||
const nextPageBtn = document.getElementById('nextPage');
|
|
||||||
|
|
||||||
if (prevPageBtn) {
|
|
||||||
prevPageBtn.addEventListener('click', () => {
|
|
||||||
if (modsPage > 0) {
|
|
||||||
modsPage--;
|
|
||||||
loadBrowseMods();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextPageBtn) {
|
|
||||||
nextPageBtn.addEventListener('click', () => {
|
|
||||||
if (modsPage < modsTotalPages - 1) {
|
|
||||||
modsPage++;
|
|
||||||
loadBrowseMods();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMyModsModal() {
|
|
||||||
const modal = document.getElementById('myModsModal');
|
|
||||||
if (modal) {
|
|
||||||
modal.classList.add('active');
|
|
||||||
loadInstalledMods();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMyModsModal() {
|
|
||||||
const modal = document.getElementById('myModsModal');
|
|
||||||
if (modal) {
|
|
||||||
modal.classList.remove('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadInstalledMods() {
|
|
||||||
try {
|
|
||||||
const modsPath = await window.electronAPI?.getModsPath();
|
|
||||||
if (!modsPath) {
|
|
||||||
showInstalledModsError('Could not get mods directory');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mods = await window.electronAPI?.loadInstalledMods(modsPath);
|
|
||||||
installedMods = mods || [];
|
|
||||||
|
|
||||||
displayInstalledMods(installedMods);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading installed mods:', error);
|
|
||||||
showInstalledModsError('Failed to load installed mods');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayInstalledMods(mods) {
|
|
||||||
const modsContainer = document.getElementById('installedModsList');
|
|
||||||
if (!modsContainer) return;
|
|
||||||
|
|
||||||
if (mods.length === 0) {
|
|
||||||
modsContainer.innerHTML = `
|
|
||||||
<div class=\"empty-installed-mods\">
|
|
||||||
<i class=\"fas fa-box-open\"></i>
|
|
||||||
<h4 data-i18n="mods.noModsInstalled">No Mods Installed</h4>
|
|
||||||
<p data-i18n="mods.noModsInstalledDesc">Add mods from CurseForge or import local files</p>
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
modsContainer.innerHTML = mods.map(mod => createInstalledModCard(mod)).join('');
|
|
||||||
|
|
||||||
mods.forEach(mod => {
|
|
||||||
const toggleBtn = document.getElementById(`toggle-installed-${mod.id}`);
|
|
||||||
const deleteBtn = document.getElementById(`delete-installed-${mod.id}`);
|
|
||||||
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.addEventListener('click', () => toggleMod(mod.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteBtn) {
|
|
||||||
deleteBtn.addEventListener('click', () => deleteMod(mod.id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createInstalledModCard(mod) {
|
|
||||||
const statusClass = mod.enabled ? 'text-primary' : 'text-zinc-500';
|
|
||||||
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 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';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="installed-mod-card" data-mod-id="${mod.id}">
|
|
||||||
<div class="installed-mod-icon">
|
|
||||||
<i class="fas fa-cube"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="installed-mod-info">
|
|
||||||
<div class="installed-mod-header">
|
|
||||||
<h4 class="installed-mod-name">${mod.name}</h4>
|
|
||||||
<span class="installed-mod-version">v${mod.version}</span>
|
|
||||||
</div>
|
|
||||||
<p class="installed-mod-description">${mod.description || (window.i18n ? window.i18n.t('mods.noDescription') : 'No description available')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="installed-mod-actions">
|
|
||||||
<div class="installed-mod-status ${statusClass}">
|
|
||||||
<i class="fas fa-circle"></i>
|
|
||||||
${statusText}
|
|
||||||
</div>
|
|
||||||
<div class="installed-mod-buttons">
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
<button id="toggle-installed-${mod.id}" class="installed-mod-btn-toggle ${toggleBtnClass}">
|
|
||||||
<i class="fas ${toggleIcon}"></i>
|
|
||||||
${toggleBtnText}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadBrowseMods() {
|
|
||||||
const browseContainer = document.getElementById('browseModsList');
|
|
||||||
if (!browseContainer) return;
|
|
||||||
|
|
||||||
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 {
|
|
||||||
if (!API_KEY || API_KEY.length < 10) {
|
|
||||||
browseContainer.innerHTML = `
|
|
||||||
<div class=\"empty-browse-mods\">
|
|
||||||
<i class=\"fas fa-key\"></i>
|
|
||||||
<h4>API Key Required</h4>
|
|
||||||
<p>CurseForge API key is needed to browse mods</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = modsPage * modsPageSize;
|
|
||||||
let url = `${CURSEFORGE_API}/mods/search?gameId=${HYTALE_GAME_ID}&pageSize=${modsPageSize}&sortOrder=desc&sortField=6&index=${offset}`;
|
|
||||||
|
|
||||||
if (searchQuery && searchQuery.length > 0) {
|
|
||||||
url += `&searchFilter=${encodeURIComponent(searchQuery)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Fetching mods from page', modsPage + 1, 'offset:', offset, 'search:', searchQuery || 'none', 'URL:', url);
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'x-api-key': API_KEY,
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response status:', response.status);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('API Error Response:', errorText);
|
|
||||||
throw new Error(`CurseForge API error: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('API Response data:', data);
|
|
||||||
console.log('Total mods found:', data.data?.length || 0);
|
|
||||||
|
|
||||||
browseMods = (data.data || []).map(mod => ({
|
|
||||||
id: mod.id.toString(),
|
|
||||||
name: mod.name,
|
|
||||||
slug: mod.slug,
|
|
||||||
summary: mod.summary || 'No description available',
|
|
||||||
downloadCount: mod.downloadCount || 0,
|
|
||||||
author: mod.authors?.[0]?.name || 'Unknown',
|
|
||||||
version: mod.latestFiles?.[0]?.displayName || 'Unknown',
|
|
||||||
thumbnailUrl: mod.logo?.thumbnailUrl || null,
|
|
||||||
websiteUrl: mod.links?.websiteUrl || null,
|
|
||||||
modId: mod.id,
|
|
||||||
fileId: mod.latestFiles?.[0]?.id,
|
|
||||||
fileName: mod.latestFiles?.[0]?.fileName,
|
|
||||||
downloadUrl: mod.latestFiles?.[0]?.downloadUrl
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log('Processed mods:', browseMods.length);
|
|
||||||
|
|
||||||
modsTotalPages = Math.ceil((data.pagination?.totalCount || 1) / modsPageSize);
|
|
||||||
displayBrowseMods(browseMods);
|
|
||||||
updatePagination();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading browse mods:', error);
|
|
||||||
browseContainer.innerHTML = `
|
|
||||||
<div class=\"empty-browse-mods error\">
|
|
||||||
<i class=\"fas fa-exclamation-triangle\"></i>
|
|
||||||
<h4>API Error</h4>
|
|
||||||
<p>Failed to load mods from CurseForge</p>
|
|
||||||
<small>${error.message}</small>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayBrowseMods(mods) {
|
|
||||||
const browseContainer = document.getElementById('browseModsList');
|
|
||||||
if (!browseContainer) return;
|
|
||||||
|
|
||||||
if (mods.length === 0) {
|
|
||||||
browseContainer.innerHTML = `
|
|
||||||
<div class=\"empty-browse-mods\">
|
|
||||||
<i class=\"fas fa-search\"></i>
|
|
||||||
<h4 data-i18n="mods.noModsFound">No Mods Found</h4>
|
|
||||||
<p data-i18n="mods.noModsFoundDesc">Try adjusting your search</p>
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
browseContainer.innerHTML = mods.map(mod => createBrowseModCard(mod)).join('');
|
|
||||||
|
|
||||||
mods.forEach(mod => {
|
|
||||||
const installBtn = document.getElementById(`install-${mod.id}`);
|
|
||||||
if (installBtn) {
|
|
||||||
installBtn.addEventListener('click', () => downloadAndInstallMod(mod));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBrowseModCard(mod) {
|
|
||||||
const isInstalled = installedMods.some(installed => {
|
|
||||||
// Check by CurseForge ID (most reliable)
|
|
||||||
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 `
|
|
||||||
<div class=\"mod-card ${isInstalled ? 'installed' : ''}\" data-mod-id=\"${mod.id}\">
|
|
||||||
<div class=\"mod-image\">
|
|
||||||
${mod.thumbnailUrl ?
|
|
||||||
`<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>`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class=\"mod-info\">
|
|
||||||
<div class=\"mod-header\">
|
|
||||||
<h3 class=\"mod-name\">${mod.name}</h3>
|
|
||||||
<span class=\"mod-version\">${mod.version}</span>
|
|
||||||
</div>
|
|
||||||
<p class=\"mod-description\">${mod.summary}</p>
|
|
||||||
<div class=\"mod-meta\">
|
|
||||||
<span class=\"mod-meta-item\">
|
|
||||||
<i class=\"fas fa-user\"></i>
|
|
||||||
${mod.author}
|
|
||||||
</span>
|
|
||||||
<span class=\"mod-meta-item\">
|
|
||||||
<i class=\"fas fa-download\"></i>
|
|
||||||
${formatNumber(mod.downloadCount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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})\">
|
|
||||||
<i class=\"fas fa-external-link-alt\"></i>
|
|
||||||
${window.i18n ? window.i18n.t('mods.view') : 'VIEW'}
|
|
||||||
</button>
|
|
||||||
${!isInstalled ?
|
|
||||||
`<button id="install-${mod.id}" class="mod-btn-toggle bg-primary text-black hover:bg-primary/80">
|
|
||||||
<i class="fas fa-download"></i>
|
|
||||||
${window.i18n ? window.i18n.t('mods.install') : 'INSTALL'}
|
|
||||||
</button>` :
|
|
||||||
`<button class="mod-btn-toggle bg-white/10 text-white" disabled>
|
|
||||||
<i class="fas fa-check"></i>
|
|
||||||
${window.i18n ? window.i18n.t('mods.installed') : 'INSTALLED'}
|
|
||||||
</button>`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadAndInstallMod(modInfo) {
|
|
||||||
try {
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (result?.success) {
|
|
||||||
const newMod = {
|
|
||||||
id: result.modInfo.id,
|
|
||||||
name: modInfo.name,
|
|
||||||
version: modInfo.version,
|
|
||||||
description: modInfo.summary,
|
|
||||||
author: modInfo.author,
|
|
||||||
enabled: true,
|
|
||||||
fileName: result.fileName,
|
|
||||||
fileSize: result.modInfo.fileSize,
|
|
||||||
dateInstalled: new Date().toISOString(),
|
|
||||||
curseForgeId: modInfo.modId,
|
|
||||||
curseForgeFileId: modInfo.fileId
|
|
||||||
};
|
|
||||||
|
|
||||||
installedMods.push(newMod);
|
|
||||||
|
|
||||||
await loadInstalledMods();
|
|
||||||
await loadBrowseMods();
|
|
||||||
window.LauncherUI?.hideProgress();
|
|
||||||
const successMsg = window.i18n ? window.i18n.t('notifications.modsInstalledSuccess').replace('{name}', modInfo.name) : `${modInfo.name} installed successfully! 🎉`;
|
|
||||||
showNotification(successMsg, 'success');
|
|
||||||
} else {
|
|
||||||
throw new Error(result?.error || 'Failed to download mod');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error downloading mod:', error);
|
|
||||||
window.LauncherUI?.hideProgress();
|
|
||||||
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) {
|
|
||||||
try {
|
|
||||||
const toggleMsg = window.i18n ? window.i18n.t('notifications.modsTogglingMod') : 'Toggling mod...';
|
|
||||||
window.LauncherUI?.showProgress(toggleMsg);
|
|
||||||
|
|
||||||
const modsPath = await window.electronAPI?.getModsPath();
|
|
||||||
const result = await window.electronAPI?.toggleMod(modId, modsPath);
|
|
||||||
|
|
||||||
if (result?.success) {
|
|
||||||
await loadInstalledMods();
|
|
||||||
window.LauncherUI?.hideProgress();
|
|
||||||
} else {
|
|
||||||
throw new Error(result?.error || 'Failed to toggle mod');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling mod:', error);
|
|
||||||
window.LauncherUI?.hideProgress();
|
|
||||||
const errorMsg = window.i18n ? window.i18n.t('notifications.modsToggleFailed').replace('{error}', error.message) : 'Failed to toggle mod: ' + error.message;
|
|
||||||
showNotification(errorMsg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteMod(modId) {
|
|
||||||
const mod = installedMods.find(m => m.id === modId);
|
|
||||||
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(
|
|
||||||
confirmMsg,
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const deleteMsg = window.i18n ? window.i18n.t('notifications.modsDeletingMod') : 'Deleting mod...';
|
|
||||||
window.LauncherUI?.showProgress(deleteMsg);
|
|
||||||
|
|
||||||
const modsPath = await window.electronAPI?.getModsPath();
|
|
||||||
const result = await window.electronAPI?.uninstallMod(modId, modsPath);
|
|
||||||
|
|
||||||
if (result?.success) {
|
|
||||||
await loadInstalledMods();
|
|
||||||
await loadBrowseMods();
|
|
||||||
window.LauncherUI?.hideProgress();
|
|
||||||
const successMsg = window.i18n ? window.i18n.t('notifications.modsDeletedSuccess').replace('{name}', mod.name) : `"${mod.name}" deleted successfully`;
|
|
||||||
showNotification(successMsg, 'success');
|
|
||||||
} else {
|
|
||||||
throw new Error(result?.error || 'Failed to delete mod');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting mod:', error);
|
|
||||||
window.LauncherUI?.hideProgress();
|
|
||||||
const errorMsg = window.i18n ? window.i18n.t('notifications.modsDeleteFailed').replace('{error}', error.message) : 'Failed to delete mod: ' + error.message;
|
|
||||||
showNotification(errorMsg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(num) {
|
|
||||||
if (!num) return '0';
|
|
||||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
|
||||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
|
||||||
return num.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNotification(message, type = 'info', duration = 4000) {
|
|
||||||
const existing = document.querySelector(`.mod-notification.${type}`);
|
|
||||||
if (existing) {
|
|
||||||
existing.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = `mod-notification ${type}`;
|
|
||||||
|
|
||||||
const icons = {
|
|
||||||
success: 'fa-check-circle',
|
|
||||||
error: 'fa-exclamation-circle',
|
|
||||||
info: 'fa-info-circle',
|
|
||||||
warning: 'fa-exclamation-triangle'
|
|
||||||
};
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
success: '#10b981',
|
|
||||||
error: '#ef4444',
|
|
||||||
info: '#3b82f6',
|
|
||||||
warning: '#f59e0b'
|
|
||||||
};
|
|
||||||
|
|
||||||
notification.innerHTML = `
|
|
||||||
<div class="notification-content">
|
|
||||||
<i class="fas ${icons[type]}"></i>
|
|
||||||
<span>${message}</span>
|
|
||||||
</div>
|
|
||||||
<button class="notification-close" onclick="this.parentElement.remove()">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
notification.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background: ${colors[type]};
|
|
||||||
color: white;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
z-index: 10000;
|
|
||||||
min-width: 300px;
|
|
||||||
max-width: 400px;
|
|
||||||
transform: translateX(100%);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const contentStyle = `
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex: 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const closeStyle = `
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
opacity: 0.8;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
margin-left: 10px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
notification.querySelector('.notification-content').style.cssText = contentStyle;
|
|
||||||
notification.querySelector('.notification-close').style.cssText = closeStyle;
|
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
|
|
||||||
// Animate in
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.style.transform = 'translateX(0)';
|
|
||||||
}, 10);
|
|
||||||
|
|
||||||
// Auto remove
|
|
||||||
setTimeout(() => {
|
|
||||||
if (notification.parentElement) {
|
|
||||||
notification.style.transform = 'translateX(100%)';
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.remove();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showConfirmModal(message, onConfirm, onCancel = null) {
|
|
||||||
const existingModal = document.querySelector('.mod-confirm-modal');
|
|
||||||
if (existingModal) {
|
|
||||||
existingModal.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'mod-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 = 'mod-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;">${window.i18n ? window.i18n.t('mods.confirmDeletion') : 'Confirm Deletion'}</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="mod-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;
|
|
||||||
">${window.i18n ? window.i18n.t('common.cancel') : 'Cancel'}</button>
|
|
||||||
<button class="mod-confirm-delete" style="
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s;
|
|
||||||
">${window.i18n ? window.i18n.t('common.delete') : 'Delete'}</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('.mod-confirm-cancel');
|
|
||||||
const deleteBtn = dialog.querySelector('.mod-confirm-delete');
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
modal.style.opacity = '0';
|
|
||||||
dialog.style.transform = 'scale(0.9)';
|
|
||||||
setTimeout(() => {
|
|
||||||
modal.remove();
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
cancelBtn.onclick = () => {
|
|
||||||
closeModal();
|
|
||||||
if (onCancel) onCancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteBtn.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePagination() {
|
|
||||||
const currentPageEl = document.getElementById('currentPage');
|
|
||||||
const totalPagesEl = document.getElementById('totalPages');
|
|
||||||
const prevBtn = document.getElementById('prevPage');
|
|
||||||
const nextBtn = document.getElementById('nextPage');
|
|
||||||
|
|
||||||
if (currentPageEl) currentPageEl.textContent = modsPage + 1;
|
|
||||||
if (totalPagesEl) totalPagesEl.textContent = modsTotalPages;
|
|
||||||
|
|
||||||
if (prevBtn) {
|
|
||||||
prevBtn.disabled = modsPage === 0;
|
|
||||||
prevBtn.style.opacity = modsPage === 0 ? '0.5' : '1';
|
|
||||||
prevBtn.style.cursor = modsPage === 0 ? 'not-allowed' : 'pointer';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextBtn) {
|
|
||||||
nextBtn.disabled = modsPage >= modsTotalPages - 1;
|
|
||||||
nextBtn.style.opacity = modsPage >= modsTotalPages - 1 ? '0.5' : '1';
|
|
||||||
nextBtn.style.cursor = modsPage >= modsTotalPages - 1 ? 'not-allowed' : 'pointer';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showInstalledModsError(message) {
|
|
||||||
const modsContainer = document.getElementById('installedModsList');
|
|
||||||
if (!modsContainer) return;
|
|
||||||
|
|
||||||
modsContainer.innerHTML = `
|
|
||||||
<div class=\"empty-installed-mods error\">
|
|
||||||
<i class=\"fas fa-exclamation-triangle\"></i>
|
|
||||||
<h4>Error</h4>
|
|
||||||
<p>${message}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewModPage(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 })));
|
|
||||||
|
|
||||||
const mod = browseMods.find(m => m.id.toString() === modId.toString());
|
|
||||||
if (mod) {
|
|
||||||
console.log('Found mod:', mod.name);
|
|
||||||
let modUrl;
|
|
||||||
if (mod.websiteUrl && mod.websiteUrl.includes('curseforge.com')) {
|
|
||||||
modUrl = mod.websiteUrl;
|
|
||||||
} else if (mod.slug) {
|
|
||||||
modUrl = `https://www.curseforge.com/hytale/mods/${mod.slug}`;
|
|
||||||
} else {
|
|
||||||
const nameSlug = mod.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
||||||
modUrl = `https://www.curseforge.com/hytale/mods/${nameSlug}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Opening URL:', modUrl);
|
|
||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.openExternalLink) {
|
|
||||||
window.electronAPI.openExternalLink(modUrl);
|
|
||||||
} else {
|
|
||||||
if (window.electronAPI && window.electronAPI.shell) {
|
|
||||||
window.electronAPI.shell.openExternal(modUrl);
|
|
||||||
} else {
|
|
||||||
window.open(modUrl, '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Mod not found with ID:', modId);
|
|
||||||
const errorMsg = window.i18n ? window.i18n.t('notifications.modsModNotFound') : 'Mod information not found';
|
|
||||||
showNotification(errorMsg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.modsManager = {
|
|
||||||
toggleMod,
|
|
||||||
deleteMod,
|
|
||||||
openMyModsModal,
|
|
||||||
closeMyModsModal,
|
|
||||||
viewModPage,
|
|
||||||
loadInstalledMods,
|
|
||||||
loadBrowseMods
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initModsManager);
|
|
||||||
124
GUI/js/news.js
124
GUI/js/news.js
@@ -1,124 +0,0 @@
|
|||||||
|
|
||||||
let newsData = [];
|
|
||||||
|
|
||||||
export async function loadNews() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.getHytaleNews) {
|
|
||||||
try {
|
|
||||||
const realNews = await window.electronAPI.getHytaleNews();
|
|
||||||
if (realNews && realNews.length > 0) {
|
|
||||||
newsData = realNews.slice(0, 10).map((article, index) => ({
|
|
||||||
id: index + 1,
|
|
||||||
title: article.title,
|
|
||||||
summary: article.description,
|
|
||||||
type: "NEWS",
|
|
||||||
image: article.imageUrl || '',
|
|
||||||
date: formatDate(article.date),
|
|
||||||
url: article.destUrl
|
|
||||||
}));
|
|
||||||
displayHomeNews(newsData.slice(0, 5));
|
|
||||||
displayFullNews(newsData);
|
|
||||||
} else {
|
|
||||||
showErrorNews();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Failed to load news:', error.message);
|
|
||||||
showErrorNews();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showErrorNews();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading news:', error);
|
|
||||||
showErrorNews();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayHomeNews(news) {
|
|
||||||
const newsGrid = document.getElementById('newsGrid');
|
|
||||||
if (!newsGrid) return;
|
|
||||||
|
|
||||||
newsGrid.innerHTML = news.map(article => `
|
|
||||||
<div class="news-item news-card" onclick="openNewsDetails(${article.id})">
|
|
||||||
<div class="news-image" style="background-image: url('${article.image}');"></div>
|
|
||||||
<div class="news-overlay">
|
|
||||||
<span class="news-type">${article.type}</span>
|
|
||||||
<span class="news-date">${article.date}</span>
|
|
||||||
</div>
|
|
||||||
<div class="news-content">
|
|
||||||
<h3 class="news-title">${article.title}</h3>
|
|
||||||
<p class="news-summary">${article.summary}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayFullNews(news) {
|
|
||||||
const allNewsGrid = document.getElementById('allNewsGrid');
|
|
||||||
if (!allNewsGrid) return;
|
|
||||||
|
|
||||||
allNewsGrid.innerHTML = news.map(article => `
|
|
||||||
<div class="news-item news-card" onclick="openNewsDetails(${article.id})">
|
|
||||||
<div class="news-image" style="background-image: url('${article.image}');"></div>
|
|
||||||
<div class="news-overlay">
|
|
||||||
<span class="news-type">${article.type}</span>
|
|
||||||
<span class="news-date">${article.date}</span>
|
|
||||||
</div>
|
|
||||||
<div class="news-content">
|
|
||||||
<h3 class="news-title">${article.title}</h3>
|
|
||||||
<p class="news-summary">${article.summary}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showErrorNews() {
|
|
||||||
const newsGrid = document.getElementById('newsGrid');
|
|
||||||
if (newsGrid) {
|
|
||||||
newsGrid.innerHTML = `
|
|
||||||
<div class="loading-news">
|
|
||||||
<i class="fas fa-exclamation-triangle text-4xl mb-4 text-yellow-500"></i>
|
|
||||||
<span>Unable to load news</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openNewsDetails(newsId) {
|
|
||||||
const article = newsData.find(item => item.id === newsId);
|
|
||||||
if (article && article.url) {
|
|
||||||
openNewsArticle(article.url);
|
|
||||||
} else {
|
|
||||||
console.log('Opening news article:', article);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openNewsArticle(url) {
|
|
||||||
if (url && url !== '#' && window.electronAPI && window.electronAPI.openExternal) {
|
|
||||||
window.electronAPI.openExternal(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString) {
|
|
||||||
if (!dateString) return 'RECENTLY';
|
|
||||||
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const now = new Date();
|
|
||||||
const diffTime = Math.abs(now - date);
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays === 1) return '1 DAY AGO';
|
|
||||||
if (diffDays < 7) return `${diffDays} DAYS AGO`;
|
|
||||||
if (diffDays < 30) return `${Math.ceil(diffDays / 7)} WEEKS AGO`;
|
|
||||||
return date.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.openNewsDetails = openNewsDetails;
|
|
||||||
window.navigateToPage = (page) => {
|
|
||||||
if (window.LauncherUI) {
|
|
||||||
window.LauncherUI.showPage(`${page}-page`);
|
|
||||||
window.LauncherUI.setActiveNav(page);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', loadNews);
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
|
|
||||||
const API_URL = 'https://api.hytalef2p.com/api';
|
|
||||||
let updateInterval = null;
|
|
||||||
let currentUserId = null;
|
|
||||||
|
|
||||||
export async function initPlayersCounter() {
|
|
||||||
setupPlayersCounter();
|
|
||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.getUserId) {
|
|
||||||
currentUserId = await window.electronAPI.getUserId();
|
|
||||||
} else {
|
|
||||||
console.error('Electron API not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let username = 'Player';
|
|
||||||
if (window.electronAPI.loadUsername) {
|
|
||||||
const savedUsername = await window.electronAPI.loadUsername();
|
|
||||||
if (savedUsername) username = savedUsername;
|
|
||||||
}
|
|
||||||
|
|
||||||
await registerPlayer(username, currentUserId);
|
|
||||||
|
|
||||||
await fetchPlayerStats();
|
|
||||||
startAutoUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupPlayersCounter() {
|
|
||||||
const counterElement = document.getElementById('playersOnlineCounter');
|
|
||||||
if (!counterElement) {
|
|
||||||
console.warn('Players counter element not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchPlayerStats() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}/players/stats`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API error: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
updateCounterDisplay(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching player stats:', error);
|
|
||||||
updateCounterDisplay({ online: 0, peak: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCounterDisplay(stats) {
|
|
||||||
const counterElement = document.getElementById('playersOnlineCounter');
|
|
||||||
const onlineCount = document.getElementById('onlineCount');
|
|
||||||
|
|
||||||
if (onlineCount) {
|
|
||||||
onlineCount.textContent = stats.online || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (counterElement) {
|
|
||||||
counterElement.classList.add('updated');
|
|
||||||
setTimeout(() => {
|
|
||||||
counterElement.classList.remove('updated');
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function registerPlayer(username, userId) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}/players/register`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ username, userId })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to register player: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
currentUserId = userId;
|
|
||||||
console.log('Player registered:', data);
|
|
||||||
|
|
||||||
await fetchPlayerStats();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error registering player:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unregisterPlayer(userId) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}/players/unregister`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ userId })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to unregister player: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
currentUserId = null;
|
|
||||||
console.log('Player unregistered:', data);
|
|
||||||
|
|
||||||
await fetchPlayerStats();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error unregistering player:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAutoUpdate() {
|
|
||||||
updateInterval = setInterval(async () => {
|
|
||||||
await fetchPlayerStats();
|
|
||||||
|
|
||||||
if (currentUserId) {
|
|
||||||
const username = window.LauncherState?.username || 'Player';
|
|
||||||
await registerPlayer(username, currentUserId);
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAutoUpdate() {
|
|
||||||
if (updateInterval) {
|
|
||||||
clearInterval(updateInterval);
|
|
||||||
updateInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
if (currentUserId) {
|
|
||||||
const data = JSON.stringify({ userId: currentUserId });
|
|
||||||
navigator.sendBeacon(`${API_URL}/players/unregister`, data);
|
|
||||||
}
|
|
||||||
stopAutoUpdate();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.PlayersAPI = {
|
|
||||||
register: registerPlayer,
|
|
||||||
unregister: unregisterPlayer,
|
|
||||||
fetchStats: fetchPlayerStats
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initPlayersCounter);
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import './ui.js';
|
|
||||||
import './install.js';
|
|
||||||
import './launcher.js';
|
|
||||||
import './news.js';
|
|
||||||
import './mods.js';
|
|
||||||
import './players.js';
|
|
||||||
import './chat.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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.closeDiscordNotification = function() {
|
|
||||||
const notification = document.getElementById('discordNotification');
|
|
||||||
if (notification) {
|
|
||||||
notification.classList.add('hidden');
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.style.display = 'none';
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
localStorage.setItem('discordNotificationDismissed', 'true');
|
|
||||||
};
|
|
||||||
@@ -1,894 +0,0 @@
|
|||||||
|
|
||||||
let customJavaCheck;
|
|
||||||
let customJavaOptions;
|
|
||||||
let customJavaPath;
|
|
||||||
let browseJavaBtn;
|
|
||||||
let settingsPlayerName;
|
|
||||||
let discordRPCCheck;
|
|
||||||
let closeLauncherCheck;
|
|
||||||
let gpuPreferenceRadios;
|
|
||||||
|
|
||||||
|
|
||||||
// UUID Management elements
|
|
||||||
let currentUuidDisplay;
|
|
||||||
let copyUuidBtn;
|
|
||||||
let regenerateUuidBtn;
|
|
||||||
let manageUuidsBtn;
|
|
||||||
let uuidModal;
|
|
||||||
let uuidModalClose;
|
|
||||||
let modalCurrentUuid;
|
|
||||||
let modalCopyUuidBtn;
|
|
||||||
let modalRegenerateUuidBtn;
|
|
||||||
let generateNewUuidBtn;
|
|
||||||
let uuidList;
|
|
||||||
let customUuidInput;
|
|
||||||
let setCustomUuidBtn;
|
|
||||||
|
|
||||||
function showCustomConfirm(message, title, onConfirm, onCancel = null, confirmText, cancelText) {
|
|
||||||
// Apply defaults with i18n support
|
|
||||||
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(147, 51, 234, 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: #9333ea;">
|
|
||||||
<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: linear-gradient(135deg, #9333ea, #3b82f6);
|
|
||||||
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 function initSettings() {
|
|
||||||
setupSettingsElements();
|
|
||||||
loadAllSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSettingsElements() {
|
|
||||||
customJavaCheck = document.getElementById('customJavaCheck');
|
|
||||||
customJavaOptions = document.getElementById('customJavaOptions');
|
|
||||||
customJavaPath = document.getElementById('customJavaPath');
|
|
||||||
browseJavaBtn = document.getElementById('browseJavaBtn');
|
|
||||||
settingsPlayerName = document.getElementById('settingsPlayerName');
|
|
||||||
discordRPCCheck = document.getElementById('discordRPCCheck');
|
|
||||||
closeLauncherCheck = document.getElementById('closeLauncherCheck');
|
|
||||||
gpuPreferenceRadios = document.querySelectorAll('input[name="gpuPreference"]');
|
|
||||||
|
|
||||||
|
|
||||||
// UUID Management elements
|
|
||||||
currentUuidDisplay = document.getElementById('currentUuid');
|
|
||||||
copyUuidBtn = document.getElementById('copyUuidBtn');
|
|
||||||
regenerateUuidBtn = document.getElementById('regenerateUuidBtn');
|
|
||||||
manageUuidsBtn = document.getElementById('manageUuidsBtn');
|
|
||||||
uuidModal = document.getElementById('uuidModal');
|
|
||||||
uuidModalClose = document.getElementById('uuidModalClose');
|
|
||||||
modalCurrentUuid = document.getElementById('modalCurrentUuid');
|
|
||||||
modalCopyUuidBtn = document.getElementById('modalCopyUuidBtn');
|
|
||||||
modalRegenerateUuidBtn = document.getElementById('modalRegenerateUuidBtn');
|
|
||||||
generateNewUuidBtn = document.getElementById('generateNewUuidBtn');
|
|
||||||
uuidList = document.getElementById('uuidList');
|
|
||||||
customUuidInput = document.getElementById('customUuidInput');
|
|
||||||
setCustomUuidBtn = document.getElementById('setCustomUuidBtn');
|
|
||||||
|
|
||||||
if (customJavaCheck) {
|
|
||||||
customJavaCheck.addEventListener('change', toggleCustomJava);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (browseJavaBtn) {
|
|
||||||
browseJavaBtn.addEventListener('click', browseJavaPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settingsPlayerName) {
|
|
||||||
settingsPlayerName.addEventListener('change', savePlayerName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discordRPCCheck) {
|
|
||||||
discordRPCCheck.addEventListener('change', saveDiscordRPC);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (closeLauncherCheck) {
|
|
||||||
closeLauncherCheck.addEventListener('change', saveCloseLauncher);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// UUID event listeners
|
|
||||||
if (copyUuidBtn) {
|
|
||||||
copyUuidBtn.addEventListener('click', copyCurrentUuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (regenerateUuidBtn) {
|
|
||||||
regenerateUuidBtn.addEventListener('click', regenerateCurrentUuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manageUuidsBtn) {
|
|
||||||
manageUuidsBtn.addEventListener('click', openUuidModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uuidModalClose) {
|
|
||||||
uuidModalClose.addEventListener('click', closeUuidModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modalCopyUuidBtn) {
|
|
||||||
modalCopyUuidBtn.addEventListener('click', copyCurrentUuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modalRegenerateUuidBtn) {
|
|
||||||
modalRegenerateUuidBtn.addEventListener('click', regenerateCurrentUuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generateNewUuidBtn) {
|
|
||||||
generateNewUuidBtn.addEventListener('click', generateNewUuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setCustomUuidBtn) {
|
|
||||||
setCustomUuidBtn.addEventListener('click', setCustomUuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uuidModal) {
|
|
||||||
uuidModal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === uuidModal) {
|
|
||||||
closeUuidModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gpuPreferenceRadios) {
|
|
||||||
gpuPreferenceRadios.forEach(radio => {
|
|
||||||
radio.addEventListener('change', async () => {
|
|
||||||
await saveGpuPreference();
|
|
||||||
await updateGpuLabel();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCustomJava() {
|
|
||||||
if (!customJavaOptions) return;
|
|
||||||
|
|
||||||
if (customJavaCheck && customJavaCheck.checked) {
|
|
||||||
customJavaOptions.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
customJavaOptions.style.display = 'none';
|
|
||||||
if (customJavaPath) customJavaPath.value = '';
|
|
||||||
saveCustomJavaPath('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function browseJavaPath() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.browseJavaPath) {
|
|
||||||
const result = await window.electronAPI.browseJavaPath();
|
|
||||||
if (result && result.filePaths && result.filePaths.length > 0) {
|
|
||||||
const selectedPath = result.filePaths[0];
|
|
||||||
if (customJavaPath) {
|
|
||||||
customJavaPath.value = selectedPath;
|
|
||||||
}
|
|
||||||
await saveCustomJavaPath(selectedPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error browsing Java path:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCustomJavaPath(path) {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.saveJavaPath) {
|
|
||||||
await window.electronAPI.saveJavaPath(path);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving custom Java path:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCustomJavaPath() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.loadJavaPath) {
|
|
||||||
const savedPath = await window.electronAPI.loadJavaPath();
|
|
||||||
if (savedPath && savedPath.trim()) {
|
|
||||||
if (customJavaPath) {
|
|
||||||
customJavaPath.value = savedPath;
|
|
||||||
}
|
|
||||||
if (customJavaCheck) {
|
|
||||||
customJavaCheck.checked = true;
|
|
||||||
}
|
|
||||||
if (customJavaOptions) {
|
|
||||||
customJavaOptions.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading custom Java path:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveDiscordRPC() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.saveDiscordRPC && discordRPCCheck) {
|
|
||||||
const enabled = discordRPCCheck.checked;
|
|
||||||
console.log('Saving Discord RPC setting:', enabled);
|
|
||||||
|
|
||||||
const result = await window.electronAPI.saveDiscordRPC(enabled);
|
|
||||||
|
|
||||||
if (result && result.success) {
|
|
||||||
console.log('Discord RPC setting saved successfully:', enabled);
|
|
||||||
|
|
||||||
// Feedback visuel pour l'utilisateur
|
|
||||||
if (enabled) {
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.discordEnabled') : 'Discord Rich Presence enabled';
|
|
||||||
showNotification(msg, 'success');
|
|
||||||
} else {
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.discordDisabled') : 'Discord Rich Presence disabled';
|
|
||||||
showNotification(msg, 'success');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to save Discord RPC setting');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving Discord RPC setting:', error);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.discordSaveFailed') : 'Failed to save Discord setting';
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDiscordRPC() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.loadDiscordRPC) {
|
|
||||||
const enabled = await window.electronAPI.loadDiscordRPC();
|
|
||||||
if (discordRPCCheck) {
|
|
||||||
discordRPCCheck.checked = enabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading Discord RPC setting:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCloseLauncher() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.saveCloseLauncher && closeLauncherCheck) {
|
|
||||||
const enabled = closeLauncherCheck.checked;
|
|
||||||
await window.electronAPI.saveCloseLauncher(enabled);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving close launcher setting:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCloseLauncher() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.loadCloseLauncher) {
|
|
||||||
const enabled = await window.electronAPI.loadCloseLauncher();
|
|
||||||
if (closeLauncherCheck) {
|
|
||||||
closeLauncherCheck.checked = enabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading close launcher setting:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function savePlayerName() {
|
|
||||||
try {
|
|
||||||
if (!window.electronAPI || !settingsPlayerName) return;
|
|
||||||
|
|
||||||
const playerName = settingsPlayerName.value.trim();
|
|
||||||
|
|
||||||
if (!playerName) {
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.playerNameRequired') : 'Please enter a valid player name';
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await window.electronAPI.saveUsername(playerName);
|
|
||||||
const successMsg = window.i18n ? window.i18n.t('notifications.playerNameSaved') : 'Player name saved successfully';
|
|
||||||
showNotification(successMsg, 'success');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving player name:', error);
|
|
||||||
const errorMsg = window.i18n ? window.i18n.t('notifications.playerNameSaveFailed') : 'Failed to save player name';
|
|
||||||
showNotification(errorMsg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPlayerName() {
|
|
||||||
try {
|
|
||||||
if (!window.electronAPI || !settingsPlayerName) return;
|
|
||||||
|
|
||||||
const savedName = await window.electronAPI.loadUsername();
|
|
||||||
if (savedName) {
|
|
||||||
settingsPlayerName.value = savedName;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading player name:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveGpuPreference() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.saveGpuPreference && gpuPreferenceRadios) {
|
|
||||||
const gpuPreference = Array.from(gpuPreferenceRadios).find(radio => radio.checked)?.value || 'auto';
|
|
||||||
await window.electronAPI.saveGpuPreference(gpuPreference);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving GPU preference:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateGpuLabel() {
|
|
||||||
const detectionInfo = document.getElementById('gpu-detection-info');
|
|
||||||
if (!detectionInfo) return;
|
|
||||||
|
|
||||||
if (gpuPreferenceRadios) {
|
|
||||||
const checked = Array.from(gpuPreferenceRadios).find(radio => radio.checked);
|
|
||||||
if (checked) {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.getDetectedGpu) {
|
|
||||||
const detected = await window.electronAPI.getDetectedGpu();
|
|
||||||
if (checked.value === 'auto') {
|
|
||||||
if (detected.dedicatedName) {
|
|
||||||
detectionInfo.textContent = `dGPU detected, using ${detected.dedicatedName}`;
|
|
||||||
} else {
|
|
||||||
detectionInfo.textContent = `dGPU not detected, using iGPU (${detected.integratedName}) instead`;
|
|
||||||
}
|
|
||||||
detectionInfo.style.display = 'block';
|
|
||||||
} else if (checked.value === 'integrated') {
|
|
||||||
detectionInfo.textContent = `Detected: ${detected.integratedName}`;
|
|
||||||
detectionInfo.style.display = 'block';
|
|
||||||
} else if (checked.value === 'dedicated') {
|
|
||||||
if (detected.dedicatedName) {
|
|
||||||
detectionInfo.textContent = `Detected: ${detected.dedicatedName}`;
|
|
||||||
} else {
|
|
||||||
detectionInfo.textContent = `No dedicated GPU detected`;
|
|
||||||
}
|
|
||||||
detectionInfo.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
detectionInfo.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting detected GPU:', error);
|
|
||||||
detectionInfo.style.display = 'none';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
detectionInfo.style.display = 'none';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
detectionInfo.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGpuPreference() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.loadGpuPreference && gpuPreferenceRadios) {
|
|
||||||
const savedPreference = await window.electronAPI.loadGpuPreference();
|
|
||||||
if (savedPreference) {
|
|
||||||
for (const radio of gpuPreferenceRadios) {
|
|
||||||
if (radio.value === savedPreference) {
|
|
||||||
radio.checked = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await updateGpuLabel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading GPU preference:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAllSettings() {
|
|
||||||
await loadCustomJavaPath();
|
|
||||||
await loadPlayerName();
|
|
||||||
await loadCurrentUuid();
|
|
||||||
await loadDiscordRPC();
|
|
||||||
await loadCloseLauncher();
|
|
||||||
await loadGpuPreference();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function openGameLocation() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.openGameLocation) {
|
|
||||||
await window.electronAPI.openGameLocation();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error opening game location:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCurrentJavaPath() {
|
|
||||||
if (customJavaCheck && customJavaCheck.checked && customJavaPath) {
|
|
||||||
return customJavaPath.value.trim();
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function getCurrentPlayerName() {
|
|
||||||
if (settingsPlayerName && settingsPlayerName.value.trim()) {
|
|
||||||
return settingsPlayerName.value.trim();
|
|
||||||
}
|
|
||||||
return 'Player';
|
|
||||||
}
|
|
||||||
|
|
||||||
window.openGameLocation = openGameLocation;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initSettings);
|
|
||||||
|
|
||||||
window.SettingsAPI = {
|
|
||||||
getCurrentJavaPath,
|
|
||||||
getCurrentPlayerName
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadCurrentUuid() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.getCurrentUuid) {
|
|
||||||
const uuid = await window.electronAPI.getCurrentUuid();
|
|
||||||
if (uuid) {
|
|
||||||
if (currentUuidDisplay) currentUuidDisplay.value = uuid;
|
|
||||||
if (modalCurrentUuid) modalCurrentUuid.value = uuid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading current UUID:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyCurrentUuid() {
|
|
||||||
try {
|
|
||||||
const uuid = currentUuidDisplay ? currentUuidDisplay.value : modalCurrentUuid?.value;
|
|
||||||
if (uuid && navigator.clipboard) {
|
|
||||||
await navigator.clipboard.writeText(uuid);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidCopied') : 'UUID copied to clipboard!';
|
|
||||||
showNotification(msg, 'success');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error copying UUID:', error);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidCopyFailed') : 'Failed to copy UUID';
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function regenerateCurrentUuid() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.resetCurrentUserUuid) {
|
|
||||||
const message = window.i18n ? window.i18n.t('confirm.regenerateUuidMessage') : 'Are you sure you want to generate a new UUID? This will change your player identity.';
|
|
||||||
const title = window.i18n ? window.i18n.t('confirm.regenerateUuidTitle') : 'Generate New UUID';
|
|
||||||
const confirmBtn = window.i18n ? window.i18n.t('confirm.regenerateUuidButton') : 'Generate';
|
|
||||||
const cancelBtn = window.i18n ? window.i18n.t('common.cancel') : 'Cancel';
|
|
||||||
|
|
||||||
showCustomConfirm(
|
|
||||||
message,
|
|
||||||
title,
|
|
||||||
async () => {
|
|
||||||
await performRegenerateUuid();
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
confirmBtn,
|
|
||||||
cancelBtn
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error('electronAPI.resetCurrentUserUuid not available');
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidRegenNotAvailable') : 'UUID regeneration not available';
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in regenerateCurrentUuid:', error);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidRegenFailed') : 'Failed to regenerate UUID';
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performRegenerateUuid() {
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.resetCurrentUserUuid();
|
|
||||||
if (result.success && result.uuid) {
|
|
||||||
if (currentUuidDisplay) currentUuidDisplay.value = result.uuid;
|
|
||||||
if (modalCurrentUuid) modalCurrentUuid.value = result.uuid;
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidGenerated') : 'New UUID generated successfully!';
|
|
||||||
showNotification(msg, 'success');
|
|
||||||
|
|
||||||
if (uuidModal && uuidModal.style.display !== 'none') {
|
|
||||||
await loadAllUuids();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Failed to generate new UUID');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error regenerating UUID:', error);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidRegenFailed').replace('{error}', error.message) : `Failed to regenerate UUID: ${error.message}`;
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openUuidModal() {
|
|
||||||
try {
|
|
||||||
if (uuidModal) {
|
|
||||||
uuidModal.style.display = 'flex';
|
|
||||||
uuidModal.classList.add('active');
|
|
||||||
await loadAllUuids();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error opening UUID modal:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeUuidModal() {
|
|
||||||
if (uuidModal) {
|
|
||||||
uuidModal.classList.remove('active');
|
|
||||||
setTimeout(() => {
|
|
||||||
uuidModal.style.display = 'none';
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAllUuids() {
|
|
||||||
try {
|
|
||||||
if (!uuidList) return;
|
|
||||||
|
|
||||||
uuidList.innerHTML = `
|
|
||||||
<div class="uuid-loading">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
Loading UUIDs...
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (window.electronAPI && window.electronAPI.getAllUuidMappings) {
|
|
||||||
const mappings = await window.electronAPI.getAllUuidMappings();
|
|
||||||
|
|
||||||
if (mappings.length === 0) {
|
|
||||||
uuidList.innerHTML = `
|
|
||||||
<div class="uuid-loading">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
No UUIDs found
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uuidList.innerHTML = '';
|
|
||||||
|
|
||||||
for (const mapping of mappings) {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = `uuid-list-item${mapping.isCurrent ? ' current' : ''}`;
|
|
||||||
|
|
||||||
item.innerHTML = `
|
|
||||||
<div class="uuid-item-info">
|
|
||||||
<div class="uuid-item-username">${escapeHtml(mapping.username)}</div>
|
|
||||||
<div class="uuid-item-uuid">${mapping.uuid}</div>
|
|
||||||
</div>
|
|
||||||
<div class="uuid-item-actions">
|
|
||||||
${mapping.isCurrent ? '<div class="uuid-item-current-badge">Current</div>' : ''}
|
|
||||||
<button class="uuid-item-btn copy" onclick="copyUuid('${mapping.uuid}')" title="Copy UUID">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
${!mapping.isCurrent ? `<button class="uuid-item-btn delete" onclick="deleteUuid('${escapeHtml(mapping.username)}')" title="Delete UUID">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
uuidList.appendChild(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading UUIDs:', error);
|
|
||||||
if (uuidList) {
|
|
||||||
uuidList.innerHTML = `
|
|
||||||
<div class="uuid-loading">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
|
||||||
Error loading UUIDs
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateNewUuid() {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.generateNewUuid) {
|
|
||||||
const newUuid = await window.electronAPI.generateNewUuid();
|
|
||||||
if (newUuid) {
|
|
||||||
if (customUuidInput) customUuidInput.value = newUuid;
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidGeneratedShort') : 'New UUID generated!';
|
|
||||||
showNotification(msg, 'success');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating new UUID:', error);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidGenerateFailed') : 'Failed to generate new UUID';
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setCustomUuid() {
|
|
||||||
try {
|
|
||||||
if (!customUuidInput || !customUuidInput.value.trim()) {
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidRequired') : 'Please enter a UUID';
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uuid = customUuidInput.value.trim();
|
|
||||||
|
|
||||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
||||||
if (!uuidRegex.test(uuid)) {
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidInvalidFormat') : 'Invalid UUID format';
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = window.i18n ? window.i18n.t('confirm.setCustomUuidMessage') : 'Are you sure you want to set this custom UUID? This will change your player identity.';
|
|
||||||
const title = window.i18n ? window.i18n.t('confirm.setCustomUuidTitle') : 'Set Custom UUID';
|
|
||||||
const confirmBtn = window.i18n ? window.i18n.t('confirm.setCustomUuidButton') : 'Set UUID';
|
|
||||||
const cancelBtn = window.i18n ? window.i18n.t('common.cancel') : 'Cancel';
|
|
||||||
|
|
||||||
showCustomConfirm(
|
|
||||||
message,
|
|
||||||
title,
|
|
||||||
async () => {
|
|
||||||
await performSetCustomUuid(uuid);
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
confirmBtn,
|
|
||||||
cancelBtn
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in setCustomUuid:', error);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidSetFailed') : 'Failed to set custom UUID';
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performSetCustomUuid(uuid) {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.setUuidForUser) {
|
|
||||||
const username = getCurrentPlayerName();
|
|
||||||
const result = await window.electronAPI.setUuidForUser(username, uuid);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
if (currentUuidDisplay) currentUuidDisplay.value = uuid;
|
|
||||||
if (modalCurrentUuid) modalCurrentUuid.value = uuid;
|
|
||||||
if (customUuidInput) customUuidInput.value = '';
|
|
||||||
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidSetSuccess') : 'Custom UUID set successfully!';
|
|
||||||
showNotification(msg, 'success');
|
|
||||||
|
|
||||||
await loadAllUuids();
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Failed to set custom UUID');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error setting custom UUID:', error);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidSetFailed').replace('{error}', error.message) : `Failed to set custom UUID: ${error.message}`;
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.copyUuid = async function(uuid) {
|
|
||||||
try {
|
|
||||||
if (navigator.clipboard) {
|
|
||||||
await navigator.clipboard.writeText(uuid);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidCopied') : 'UUID copied to clipboard!';
|
|
||||||
showNotification(msg, 'success');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error copying UUID:', error);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidCopyFailed') : 'Failed to copy UUID';
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.deleteUuid = async function(username) {
|
|
||||||
try {
|
|
||||||
const message = window.i18n ? window.i18n.t('confirm.deleteUuidMessage').replace('{username}', username) : `Are you sure you want to delete the UUID for "${username}"? This action cannot be undone.`;
|
|
||||||
const title = window.i18n ? window.i18n.t('confirm.deleteUuidTitle') : 'Delete UUID';
|
|
||||||
const confirmBtn = window.i18n ? window.i18n.t('confirm.deleteUuidButton') : 'Delete';
|
|
||||||
const cancelBtn = window.i18n ? window.i18n.t('common.cancel') : 'Cancel';
|
|
||||||
|
|
||||||
showCustomConfirm(
|
|
||||||
message,
|
|
||||||
title,
|
|
||||||
async () => {
|
|
||||||
await performDeleteUuid(username);
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
confirmBtn,
|
|
||||||
cancelBtn
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in deleteUuid:', error);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteFailed') : 'Failed to delete UUID';
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function performDeleteUuid(username) {
|
|
||||||
try {
|
|
||||||
if (window.electronAPI && window.electronAPI.deleteUuidForUser) {
|
|
||||||
const result = await window.electronAPI.deleteUuidForUser(username);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteSuccess') : 'UUID deleted successfully!';
|
|
||||||
showNotification(msg, 'success');
|
|
||||||
await loadAllUuids();
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Failed to delete UUID');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting UUID:', error);
|
|
||||||
const msg = window.i18n ? window.i18n.t('notifications.uuidDeleteFailed').replace('{error}', error.message) : `Failed to delete UUID: ${error.message}`;
|
|
||||||
showNotification(msg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNotification(message, type = 'info') {
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = `notification notification-${type}`;
|
|
||||||
notification.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
z-index: 10000;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(100%);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (type === 'success') {
|
|
||||||
notification.style.background = 'linear-gradient(135deg, #22c55e, #16a34a)';
|
|
||||||
} else if (type === 'error') {
|
|
||||||
notification.style.background = 'linear-gradient(135deg, #ef4444, #dc2626)';
|
|
||||||
} else {
|
|
||||||
notification.style.background = 'linear-gradient(135deg, #3b82f6, #2563eb)';
|
|
||||||
}
|
|
||||||
|
|
||||||
notification.innerHTML = `
|
|
||||||
<i class="fas fa-${type === 'success' ? 'check' : type === 'error' ? 'exclamation-triangle' : 'info-circle'}"></i>
|
|
||||||
${message}
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.style.opacity = '1';
|
|
||||||
notification.style.transform = 'translateX(0)';
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.style.opacity = '0';
|
|
||||||
notification.style.transform = 'translateX(100%)';
|
|
||||||
setTimeout(() => {
|
|
||||||
if (notification.parentNode) {
|
|
||||||
notification.parentNode.removeChild(notification);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
619
GUI/js/ui.js
619
GUI/js/ui.js
@@ -1,619 +0,0 @@
|
|||||||
|
|
||||||
let progressOverlay;
|
|
||||||
let progressBar;
|
|
||||||
let progressBarFill;
|
|
||||||
let progressText;
|
|
||||||
let progressPercent;
|
|
||||||
let progressSpeed;
|
|
||||||
let progressSize;
|
|
||||||
|
|
||||||
function showPage(pageId) {
|
|
||||||
const pages = document.querySelectorAll('.page');
|
|
||||||
pages.forEach(page => {
|
|
||||||
if (page.id === pageId) {
|
|
||||||
page.classList.add('active');
|
|
||||||
page.style.display = '';
|
|
||||||
} else {
|
|
||||||
page.classList.remove('active');
|
|
||||||
page.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setActiveNav(page) {
|
|
||||||
const navItems = document.querySelectorAll('.nav-item');
|
|
||||||
navItems.forEach(item => {
|
|
||||||
if (item.getAttribute('data-page') === page) {
|
|
||||||
item.classList.add('active');
|
|
||||||
} else {
|
|
||||||
item.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNavigation() {
|
|
||||||
const navItems = document.querySelectorAll('.nav-item');
|
|
||||||
navItems.forEach(item => {
|
|
||||||
item.addEventListener('click', () => {
|
|
||||||
const page = item.getAttribute('data-page');
|
|
||||||
showPage(`${page}-page`);
|
|
||||||
setActiveNav(page);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupWindowControls() {
|
|
||||||
const minimizeBtn = document.querySelector('.window-controls .minimize');
|
|
||||||
const closeBtn = document.querySelector('.window-controls .close');
|
|
||||||
|
|
||||||
const windowControls = document.querySelector('.window-controls');
|
|
||||||
const header = document.querySelector('.header');
|
|
||||||
|
|
||||||
const profileSelector = document.querySelector('.profile-selector');
|
|
||||||
|
|
||||||
if (profileSelector) {
|
|
||||||
profileSelector.style.pointerEvents = 'auto';
|
|
||||||
profileSelector.style.zIndex = '10000';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (windowControls) {
|
|
||||||
windowControls.style.pointerEvents = 'auto';
|
|
||||||
windowControls.style.zIndex = '10000';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (header) {
|
|
||||||
header.style.webkitAppRegion = 'drag';
|
|
||||||
if (windowControls) {
|
|
||||||
windowControls.style.webkitAppRegion = 'no-drag';
|
|
||||||
}
|
|
||||||
if (profileSelector) {
|
|
||||||
profileSelector.style.webkitAppRegion = 'no-drag';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.electronAPI) {
|
|
||||||
if (minimizeBtn) {
|
|
||||||
minimizeBtn.onclick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
window.electronAPI.minimizeWindow();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (closeBtn) {
|
|
||||||
closeBtn.onclick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
window.electronAPI.closeWindow();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLauncherOrInstall(isInstalled) {
|
|
||||||
const launcher = document.getElementById('launcher-container');
|
|
||||||
const install = document.getElementById('install-page');
|
|
||||||
const sidebar = document.querySelector('.sidebar');
|
|
||||||
const gameTitle = document.querySelector('.game-title-section');
|
|
||||||
|
|
||||||
if (isInstalled) {
|
|
||||||
if (launcher) launcher.style.display = '';
|
|
||||||
if (install) install.style.display = 'none';
|
|
||||||
if (sidebar) sidebar.style.pointerEvents = 'auto';
|
|
||||||
if (gameTitle) gameTitle.style.display = '';
|
|
||||||
showPage('play-page');
|
|
||||||
setActiveNav('play');
|
|
||||||
} else {
|
|
||||||
if (launcher) launcher.style.display = 'none';
|
|
||||||
if (install) {
|
|
||||||
install.style.display = '';
|
|
||||||
install.classList.add('active');
|
|
||||||
}
|
|
||||||
if (sidebar) sidebar.style.pointerEvents = 'none';
|
|
||||||
if (gameTitle) gameTitle.style.display = 'none';
|
|
||||||
const pages = document.querySelectorAll('#launcher-container .page');
|
|
||||||
pages.forEach(page => page.classList.remove('active'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSidebarLogo() {
|
|
||||||
const logo = document.querySelector('.sidebar-logo img');
|
|
||||||
if (logo) {
|
|
||||||
logo.addEventListener('click', () => {
|
|
||||||
showPage('play-page');
|
|
||||||
setActiveNav('play');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showProgress() {
|
|
||||||
if (progressOverlay) {
|
|
||||||
progressOverlay.style.display = 'block';
|
|
||||||
setTimeout(() => {
|
|
||||||
progressOverlay.style.opacity = '1';
|
|
||||||
progressOverlay.style.transform = 'translateY(0)';
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideProgress() {
|
|
||||||
if (progressOverlay) {
|
|
||||||
progressOverlay.style.opacity = '0';
|
|
||||||
progressOverlay.style.transform = 'translateY(20px)';
|
|
||||||
setTimeout(() => {
|
|
||||||
progressOverlay.style.display = 'none';
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateProgress(data) {
|
|
||||||
if (data.message && progressText) {
|
|
||||||
progressText.textContent = data.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.percent !== null && data.percent !== undefined) {
|
|
||||||
const percent = Math.min(100, Math.max(0, Math.round(data.percent)));
|
|
||||||
if (progressPercent) progressPercent.textContent = `${percent}%`;
|
|
||||||
if (progressBarFill) progressBarFill.style.width = `${percent}%`;
|
|
||||||
if (progressBar) progressBar.style.width = `${percent}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.speed && data.downloaded && data.total) {
|
|
||||||
const speedMB = (data.speed / 1024 / 1024).toFixed(2);
|
|
||||||
const downloadedMB = (data.downloaded / 1024 / 1024).toFixed(2);
|
|
||||||
const totalMB = (data.total / 1024 / 1024).toFixed(2);
|
|
||||||
if (progressSpeed) progressSpeed.textContent = `${speedMB} MB/s`;
|
|
||||||
if (progressSize) progressSize.textContent = `${downloadedMB} / ${totalMB} MB`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupAnimations() {
|
|
||||||
document.body.style.opacity = '0';
|
|
||||||
document.body.style.transform = 'translateY(20px)';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.style.transition = 'all 0.6s ease';
|
|
||||||
document.body.style.opacity = '1';
|
|
||||||
document.body.style.transform = 'translateY(0)';
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupFirstLaunchHandlers() {
|
|
||||||
console.log('Setting up first launch handlers...');
|
|
||||||
|
|
||||||
window.electronAPI.onFirstLaunchUpdate((data) => {
|
|
||||||
console.log('Received first launch update event:', data);
|
|
||||||
showFirstLaunchUpdateDialog(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.electronAPI.onFirstLaunchWelcome(() => {
|
|
||||||
});
|
|
||||||
|
|
||||||
window.electronAPI.onFirstLaunchProgress((data) => {
|
|
||||||
showProgress();
|
|
||||||
updateProgress(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
let lockButtonTimeout = null;
|
|
||||||
|
|
||||||
window.electronAPI.onLockPlayButton((locked) => {
|
|
||||||
lockPlayButton(locked);
|
|
||||||
|
|
||||||
if (locked) {
|
|
||||||
if (lockButtonTimeout) {
|
|
||||||
clearTimeout(lockButtonTimeout);
|
|
||||||
}
|
|
||||||
lockButtonTimeout = setTimeout(() => {
|
|
||||||
console.warn('Play button has been locked for too long, forcing unlock');
|
|
||||||
lockPlayButton(false);
|
|
||||||
lockButtonTimeout = null;
|
|
||||||
}, 20000);
|
|
||||||
} else {
|
|
||||||
if (lockButtonTimeout) {
|
|
||||||
clearTimeout(lockButtonTimeout);
|
|
||||||
lockButtonTimeout = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFirstLaunchUpdateDialog(data) {
|
|
||||||
console.log('Creating first launch modal...');
|
|
||||||
|
|
||||||
const existingModal = document.querySelector('.first-launch-modal-overlay');
|
|
||||||
if (existingModal) {
|
|
||||||
existingModal.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const modalOverlay = document.createElement('div');
|
|
||||||
modalOverlay.className = 'first-launch-modal-overlay';
|
|
||||||
modalOverlay.style.cssText = `
|
|
||||||
position: fixed !important;
|
|
||||||
top: 0 !important;
|
|
||||||
left: 0 !important;
|
|
||||||
right: 0 !important;
|
|
||||||
bottom: 0 !important;
|
|
||||||
background: rgba(0, 0, 0, 0.95) !important;
|
|
||||||
backdrop-filter: blur(10px) !important;
|
|
||||||
z-index: 999999 !important;
|
|
||||||
display: flex !important;
|
|
||||||
align-items: center !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
pointer-events: all !important;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const modalDialog = document.createElement('div');
|
|
||||||
modalDialog.className = 'first-launch-modal-dialog';
|
|
||||||
modalDialog.style.cssText = `
|
|
||||||
background: #1a1a1a !important;
|
|
||||||
border-radius: 12px !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
width: 500px !important;
|
|
||||||
max-width: 90vw !important;
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.8) !important;
|
|
||||||
border: 1px solid rgba(147, 51, 234, 0.5) !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
animation: modalSlideIn 0.3s ease-out !important;
|
|
||||||
`;
|
|
||||||
|
|
||||||
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);">
|
|
||||||
<h2 style="margin: 0; color: #fff; font-size: 1.5rem; font-weight: 600; text-align: center;">
|
|
||||||
🔄 Game Update Required
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div style="padding: 30px; color: #e5e7eb; line-height: 1.6;">
|
|
||||||
<div style="text-align: center; margin-bottom: 25px;">
|
|
||||||
<p style="font-size: 1.1rem; margin-bottom: 15px;">
|
|
||||||
An existing Hytale installation has been detected and must be updated to the latest version.
|
|
||||||
</p>
|
|
||||||
<p style="color: #10b981; font-weight: 500; margin-bottom: 20px;">
|
|
||||||
✅ Your game saves and settings will be preserved
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: rgba(59, 130, 246, 0.1); padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6; margin: 20px 0;">
|
|
||||||
<p style="margin: 8px 0; font-family: 'Courier New', monospace; font-size: 0.9em;">
|
|
||||||
<strong>📁 Location:</strong> ${data.existingGame.installPath}
|
|
||||||
</p>
|
|
||||||
<p style="margin: 8px 0; font-family: 'Courier New', monospace; font-size: 0.9em;">
|
|
||||||
<strong>💾 UserData:</strong> ${data.existingGame.hasUserData ? '✅ Found (will be preserved)' : '❌ Not found'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: rgba(234, 179, 8, 0.1); padding: 15px; border-radius: 8px; border-left: 4px solid #eab308; margin: 20px 0;">
|
|
||||||
<p style="margin: 0; color: #fbbf24; font-weight: 500; font-size: 0.95em;">
|
|
||||||
⚠️ This update is mandatory and cannot be skipped
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="padding: 25px; border-top: 1px solid rgba(255,255,255,0.1); text-align: center;">
|
|
||||||
<button id="updateGameBtn" style="
|
|
||||||
background: linear-gradient(135deg, #9333ea, #3b82f6) !important;
|
|
||||||
color: white !important;
|
|
||||||
border: none !important;
|
|
||||||
padding: 15px 30px !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
font-size: 1rem !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
transition: all 0.2s ease !important;
|
|
||||||
min-width: 200px !important;
|
|
||||||
" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
|
|
||||||
🚀 Update Game Now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
modalOverlay.appendChild(modalDialog);
|
|
||||||
|
|
||||||
modalOverlay.onclick = (e) => {
|
|
||||||
if (e.target === modalOverlay) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('keydown', function preventEscape(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(modalOverlay);
|
|
||||||
|
|
||||||
const updateBtn = document.getElementById('updateGameBtn');
|
|
||||||
updateBtn.onclick = () => {
|
|
||||||
acceptFirstLaunchUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.firstLaunchExistingGame = data.existingGame;
|
|
||||||
|
|
||||||
console.log('First launch modal created and displayed');
|
|
||||||
}
|
|
||||||
|
|
||||||
function lockPlayButton(locked) {
|
|
||||||
const playButton = document.getElementById('homePlayBtn');
|
|
||||||
|
|
||||||
if (!playButton) {
|
|
||||||
console.warn('Play button not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (locked) {
|
|
||||||
playButton.style.opacity = '0.5';
|
|
||||||
playButton.style.pointerEvents = 'none';
|
|
||||||
playButton.style.cursor = 'not-allowed';
|
|
||||||
playButton.setAttribute('data-locked', 'true');
|
|
||||||
|
|
||||||
const spanElement = playButton.querySelector('span');
|
|
||||||
if (spanElement) {
|
|
||||||
if (!playButton.getAttribute('data-original-text')) {
|
|
||||||
playButton.setAttribute('data-original-text', spanElement.textContent);
|
|
||||||
}
|
|
||||||
spanElement.textContent = window.i18n ? window.i18n.t('play.checking') : 'CHECKING...';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Play button locked');
|
|
||||||
} else {
|
|
||||||
playButton.style.opacity = '';
|
|
||||||
playButton.style.pointerEvents = '';
|
|
||||||
playButton.style.cursor = '';
|
|
||||||
playButton.removeAttribute('data-locked');
|
|
||||||
|
|
||||||
const spanElement = playButton.querySelector('span');
|
|
||||||
if (spanElement) {
|
|
||||||
// Use i18n to get the current translation instead of restoring saved text
|
|
||||||
spanElement.textContent = window.i18n ? window.i18n.t('play.playButton') : 'PLAY HYTALE';
|
|
||||||
playButton.removeAttribute('data-original-text');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Play button unlocked');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function acceptFirstLaunchUpdate() {
|
|
||||||
const existingGame = window.firstLaunchExistingGame;
|
|
||||||
|
|
||||||
if (!existingGame) {
|
|
||||||
const errorMsg = window.i18n ? window.i18n.t('notifications.gameDataNotFound') : 'Error: Game data not found';
|
|
||||||
showNotification(errorMsg, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.querySelector('.first-launch-modal-overlay');
|
|
||||||
if (modal) {
|
|
||||||
modal.style.pointerEvents = 'none';
|
|
||||||
const btn = document.getElementById('updateGameBtn');
|
|
||||||
if (btn) {
|
|
||||||
btn.style.opacity = '0.5';
|
|
||||||
btn.style.cursor = 'not-allowed';
|
|
||||||
btn.textContent = '🔄 Updating...';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
showProgress();
|
|
||||||
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);
|
|
||||||
|
|
||||||
window.electronAPI.markAsLaunched && window.electronAPI.markAsLaunched();
|
|
||||||
|
|
||||||
if (modal) {
|
|
||||||
modal.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
lockPlayButton(false);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
hideProgress();
|
|
||||||
const successMsg = window.i18n ? window.i18n.t('notifications.gameUpdatedSuccess') : 'Game updated successfully! 🎉';
|
|
||||||
showNotification(successMsg, 'success');
|
|
||||||
} else {
|
|
||||||
hideProgress();
|
|
||||||
const errorMsg = window.i18n ? window.i18n.t('notifications.updateFailed').replace('{error}', result.error) : `Update failed: ${result.error}`;
|
|
||||||
showNotification(errorMsg, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (modal) {
|
|
||||||
modal.remove();
|
|
||||||
}
|
|
||||||
lockPlayButton(false);
|
|
||||||
hideProgress();
|
|
||||||
const errorMsg = window.i18n ? window.i18n.t('notifications.updateError').replace('{error}', error.message) : `Update error: ${error.message}`;
|
|
||||||
showNotification(errorMsg, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissFirstLaunchDialog() {
|
|
||||||
const modal = document.querySelector('.first-launch-modal-overlay');
|
|
||||||
if (modal) {
|
|
||||||
modal.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
lockPlayButton(false);
|
|
||||||
window.electronAPI.markAsLaunched && window.electronAPI.markAsLaunched();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNotification(message, type = 'info') {
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = `notification notification-${type}`;
|
|
||||||
notification.textContent = message;
|
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.classList.add('show');
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.remove();
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupUI() {
|
|
||||||
progressOverlay = document.getElementById('progressOverlay');
|
|
||||||
progressBar = document.getElementById('progressBar');
|
|
||||||
progressBarFill = document.getElementById('progressBarFill');
|
|
||||||
progressText = document.getElementById('progressText');
|
|
||||||
progressPercent = document.getElementById('progressPercent');
|
|
||||||
progressSpeed = document.getElementById('progressSpeed');
|
|
||||||
progressSize = document.getElementById('progressSize');
|
|
||||||
|
|
||||||
// Setup draggable progress bar
|
|
||||||
setupProgressDrag();
|
|
||||||
|
|
||||||
lockPlayButton(true);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const playButton = document.getElementById('homePlayBtn');
|
|
||||||
if (playButton && playButton.getAttribute('data-locked') === 'true') {
|
|
||||||
const spanElement = playButton.querySelector('span');
|
|
||||||
if (spanElement && spanElement.textContent === 'CHECKING...') {
|
|
||||||
console.warn('Play button still locked after startup timeout, forcing unlock');
|
|
||||||
lockPlayButton(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 25000);
|
|
||||||
|
|
||||||
handleNavigation();
|
|
||||||
setupWindowControls();
|
|
||||||
setupSidebarLogo();
|
|
||||||
setupAnimations();
|
|
||||||
setupFirstLaunchHandlers();
|
|
||||||
loadLauncherVersion();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.LauncherUI = {
|
|
||||||
showPage,
|
|
||||||
setActiveNav,
|
|
||||||
showLauncherOrInstall,
|
|
||||||
showProgress,
|
|
||||||
hideProgress,
|
|
||||||
updateProgress
|
|
||||||
};
|
|
||||||
|
|
||||||
// Make installation effects globally available
|
|
||||||
window.showInstallationEffects = showInstallationEffects;
|
|
||||||
window.hideInstallationEffects = hideInstallationEffects;
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide installation effects
|
|
||||||
function showInstallationEffects() {
|
|
||||||
const installationEffects = document.getElementById('installationEffects');
|
|
||||||
if (installationEffects) {
|
|
||||||
installationEffects.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideInstallationEffects() {
|
|
||||||
const installationEffects = document.getElementById('installationEffects');
|
|
||||||
if (installationEffects) {
|
|
||||||
installationEffects.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle maximize/restore window function
|
|
||||||
function toggleMaximize() {
|
|
||||||
if (window.electronAPI && window.electronAPI.maximizeWindow) {
|
|
||||||
window.electronAPI.maximizeWindow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make toggleMaximize globally available
|
|
||||||
window.toggleMaximize = toggleMaximize;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', setupUI);
|
|
||||||
360
GUI/js/update.js
360
GUI/js/update.js
@@ -1,360 +0,0 @@
|
|||||||
|
|
||||||
class ClientUpdateManager {
|
|
||||||
constructor() {
|
|
||||||
this.updatePopupVisible = false;
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
window.electronAPI.onUpdatePopup((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();
|
|
||||||
}
|
|
||||||
|
|
||||||
showUpdatePopup(updateInfo) {
|
|
||||||
if (this.updatePopupVisible) return;
|
|
||||||
|
|
||||||
this.updatePopupVisible = true;
|
|
||||||
|
|
||||||
const popupHTML = `
|
|
||||||
<div id="update-popup-overlay">
|
|
||||||
<div class="update-popup-container update-popup-pulse">
|
|
||||||
<div class="update-popup-header">
|
|
||||||
<div class="update-popup-icon">
|
|
||||||
<i class="fas fa-download"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="update-popup-title">
|
|
||||||
NEW UPDATE AVAILABLE
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="update-popup-versions">
|
|
||||||
<div class="version-row">
|
|
||||||
<span class="version-label">Current Version:</span>
|
|
||||||
<span class="version-current">${updateInfo.currentVersion || updateInfo.version || 'Unknown'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="version-row">
|
|
||||||
<span class="version-label">New Version:</span>
|
|
||||||
<span class="version-new">${updateInfo.newVersion || updateInfo.version || 'Unknown'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="update-popup-message">
|
|
||||||
A new version of Hytale F2P Launcher is available.<br>
|
|
||||||
<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 id="update-progress-container" style="display: none; margin-bottom: 1rem;">
|
|
||||||
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.75rem; color: #9ca3af;">
|
|
||||||
<span id="update-progress-percent">0%</span>
|
|
||||||
<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">
|
|
||||||
This popup cannot be closed until you update the launcher
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', popupHTML);
|
|
||||||
|
|
||||||
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');
|
|
||||||
if (downloadBtn) {
|
|
||||||
downloadBtn.addEventListener('click', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
downloadBtn.disabled = true;
|
|
||||||
downloadBtn.innerHTML = '<i class="fas fa-spinner fa-spin" style="margin-right: 0.5rem;"></i>Opening GitHub...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await window.electronAPI.openDownloadPage();
|
|
||||||
console.log('✅ Download page opened, launcher will close...');
|
|
||||||
|
|
||||||
downloadBtn.innerHTML = '<i class="fas fa-check" style="margin-right: 0.5rem;"></i>Launcher closing...';
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error opening download page:', error);
|
|
||||||
downloadBtn.disabled = false;
|
|
||||||
downloadBtn.innerHTML = '<i class="fas fa-external-link-alt" style="margin-right: 0.5rem;"></i>Manually Download';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const overlay = document.getElementById('update-popup-overlay');
|
|
||||||
if (overlay) {
|
|
||||||
overlay.addEventListener('click', (e) => {
|
|
||||||
if (e.target === overlay) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
const mainContent = document.querySelector('.flex.w-full.h-screen');
|
|
||||||
if (mainContent) {
|
|
||||||
mainContent.classList.add('interface-blocked');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.classList.add('no-select');
|
|
||||||
|
|
||||||
document.addEventListener('keydown', this.blockKeyEvents.bind(this), true);
|
|
||||||
|
|
||||||
document.addEventListener('contextmenu', this.blockContextMenu.bind(this), true);
|
|
||||||
|
|
||||||
console.log('🚫 Interface blocked for update');
|
|
||||||
}
|
|
||||||
|
|
||||||
blockKeyEvents(event) {
|
|
||||||
if (event.target.closest('#update-popup-overlay')) {
|
|
||||||
if ((event.key === 'Enter' || event.key === ' ') &&
|
|
||||||
event.target.id === 'update-download-btn') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== 'Tab') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockContextMenu(event) {
|
|
||||||
if (!event.target.closest('#update-popup-overlay')) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkForUpdatesOnDemand() {
|
|
||||||
try {
|
|
||||||
const updateInfo = await window.electronAPI.checkForUpdates();
|
|
||||||
|
|
||||||
// Double-check that versions are actually different before showing popup
|
|
||||||
if (updateInfo.updateAvailable &&
|
|
||||||
updateInfo.newVersion &&
|
|
||||||
updateInfo.currentVersion &&
|
|
||||||
updateInfo.newVersion !== updateInfo.currentVersion) {
|
|
||||||
this.showUpdatePopup(updateInfo);
|
|
||||||
}
|
|
||||||
return updateInfo;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking for updates:', error);
|
|
||||||
return { updateAvailable: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
window.updateManager = new ClientUpdateManager();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.ClientUpdateManager = ClientUpdateManager;
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"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!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
178
GUI/splash.html
178
GUI/splash.html
@@ -1,178 +0,0 @@
|
|||||||
<!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>
|
|
||||||
5769
GUI/style.css
5769
GUI/style.css
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
|||||||
[Desktop Entry]
|
|
||||||
Type=Application
|
|
||||||
Name=Hytale-F2P
|
|
||||||
Comment=A modern, cross-platform launcher for Hytale with automatic updates and multi-client support
|
|
||||||
Exec=/opt/Hytale-F2P/hytale-f2p-launcher
|
|
||||||
Categories=Game;
|
|
||||||
Icon=Hytale-F2P
|
|
||||||
Terminal=false
|
|
||||||
StartupNotify=true
|
|
||||||
31
PKGBUILD
31
PKGBUILD
@@ -1,31 +0,0 @@
|
|||||||
# Maintainer: Terromur <terromuroz@proton.me>
|
|
||||||
# Maintainer: Fazri Gading <fazrigading@gmail.com>
|
|
||||||
pkgname=Hytale-F2P-git
|
|
||||||
_pkgname=Hytale-F2P
|
|
||||||
pkgver=2.0.11.r120.gb05aeef
|
|
||||||
pkgrel=1
|
|
||||||
pkgdesc="Hytale-F2P - unofficial Hytale Launcher for free to play with multiplayer support"
|
|
||||||
arch=('x86_64')
|
|
||||||
url="https://github.com/amiayweb/Hytale-F2P"
|
|
||||||
license=('custom')
|
|
||||||
makedepends=('npm' 'git' 'rpm-tools' 'libxcrypt-compat')
|
|
||||||
source=("git+$url.git" "Hytale-F2P.desktop")
|
|
||||||
sha256sums=('SKIP' '46488fada4775d9976d7b7b62f8d1f1f8d9a9a9d8f8aa9af4f2e2153019f6a30')
|
|
||||||
|
|
||||||
pkgver() {
|
|
||||||
cd "$_pkgname"
|
|
||||||
printf "2.0.11.r%s.g%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
|
||||||
cd "$_pkgname"
|
|
||||||
npm install
|
|
||||||
npm run build:linux
|
|
||||||
}
|
|
||||||
|
|
||||||
package() {
|
|
||||||
mkdir -p "$pkgdir/opt/$_pkgname"
|
|
||||||
cp -r "$_pkgname/dist/linux-unpacked/"* "$pkgdir/opt/$_pkgname"
|
|
||||||
install -Dm644 "$_pkgname.desktop" "$pkgdir/usr/share/applications/$_pkgname.desktop"
|
|
||||||
install -Dm644 "$_pkgname/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/$_pkgname.png"
|
|
||||||
}
|
|
||||||
240
README.md
240
README.md
@@ -1,239 +1,7 @@
|
|||||||
# 🎮 Hytale F2P Launcher | Multiplayer Support [Windows, MacOS, Linux]
|
This project has been taken down by poor hypixel studios.
|
||||||
|
|
||||||
<div align="center">
|
We still owning the HF2P community this is not illegal so cry.
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
**A modern, cross-platform launcher for Hytale with automatic updates and multiplayer support (all OS supported)**
|
|
||||||
|
|
||||||
[](https://github.com/amiayweb/Hytale-F2P/stargazers)
|
|
||||||
[](https://github.com/amiayweb/Hytale-F2P/network/members)
|
|
||||||
|
|
||||||
⭐ **If you find this project useful, please give it a star!** ⭐
|
|
||||||
|
|
||||||
🛑 **Found a problem? Join the Discord: https://discord.gg/gME8rUy3MB** 🛑
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
## 📸 Screenshots
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
## ✨ Features
|
|
||||||
|
|
||||||
🎯 **Core Features**
|
|
||||||
- 🔄 **Automatic Updates** - Smart version checking and seamless game updates
|
|
||||||
- 💾 **Data Preservation** - Intelligent UserData backup and restoration during updates
|
|
||||||
- 🌐 **Cross-Platform** - Full support for Windows, Linux (X11/Wayland), and macOS
|
|
||||||
- ☕ **Java Management** - Automatic Java runtime detection and installation
|
|
||||||
- 🎮 **Multiplayer Support** - Automatic multiplayer client installation (Windows, macOS & Linux !)
|
|
||||||
|
|
||||||
🛡️ **Advanced Features**
|
|
||||||
- 📁 **Custom Installation** - Choose your own installation directory
|
|
||||||
- 🔍 **Smart Detection** - Automatic game and dependency detection
|
|
||||||
- 🗂️ **Mod Support** - Built-in mod management system
|
|
||||||
- 💬 **Player Chat** - Integrated chat system for community interaction
|
|
||||||
- 📰 **News Feed** - Stay updated with the latest Hytale news
|
|
||||||
- 🎨 **Modern UI** - Clean, responsive interface with dark theme
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### 📥 Installation
|
|
||||||
|
|
||||||
#### Windows
|
|
||||||
1. Download the latest `Hytale-F2P.exe` from [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases)
|
|
||||||
2. Run the installer
|
|
||||||
3. Launch from desktop or start menu
|
|
||||||
|
|
||||||
#### Linux
|
|
||||||
See [BUILD.md](BUILD.md) for detailed build instructions or [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases) section.
|
|
||||||
|
|
||||||
#### macOS
|
|
||||||
See [BUILD.md](BUILD.md) for detailed build instructions or [**Releases**](https://github.com/amiayweb/Hytale-F2P/releases) section.
|
|
||||||
|
|
||||||
#### 🖥️ How to play online on F2P?
|
|
||||||
See [SERVER.md](SERVER.md)
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Building from Source
|
|
||||||
|
|
||||||
See [BUILD.md](BUILD.md) for comprehensive build instructions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📌 Versioning Policy
|
|
||||||
|
|
||||||
**⚠️ Important: Semantic Versioning Required**
|
|
||||||
|
|
||||||
This project follows **strict semantic versioning** with **numerical versions only**:
|
|
||||||
|
|
||||||
- ✅ **Valid**: `2.0.1`, `2.0.11`, `2.1.0`, `3.0.0`
|
|
||||||
- ❌ **Invalid**: `2.0.2b`, `2.0.2a`, `2.0.1-beta`, `v2.0.2b`
|
|
||||||
|
|
||||||
**Format**: `MAJOR.MINOR.PATCH` (e.g., `2.0.11`)
|
|
||||||
|
|
||||||
- **MAJOR**: Breaking changes
|
|
||||||
- **MINOR**: New features (backward compatible)
|
|
||||||
- **PATCH**: Bug fixes (backward compatible)
|
|
||||||
|
|
||||||
**Why?** The auto-update system requires semantic versioning for proper version comparison. Letter suffixes (like `2.0.2b`) are not supported and will cause update detection issues.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Changelog
|
|
||||||
|
|
||||||
### 🆕 v2.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`. Mods now save to the **correct location** and load properly in-game.
|
|
||||||
- 🛡️ **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
|
|
||||||
- 🔧 **Play Button Fix** - Resolved issue where play button could get stuck in "CHECKING..." state
|
|
||||||
- 💬 **Discord Integration** - Added closable Discord notification for community engagement
|
|
||||||
- 📁 **Game Location Access** - New "Open Game Location" button in settings for easy file access
|
|
||||||
- 🎯 **UI Polish** - Removed bounce animation from player counter for smoother experience
|
|
||||||
- 🛡️ **Stability Improvements** - Enhanced error handling and process lifecycle management
|
|
||||||
- ⚡ **Performance Optimizations** - Faster startup times and better resource management
|
|
||||||
- 🔄 **Timeout Protection** - Added safety timeouts to prevent launcher freezing
|
|
||||||
|
|
||||||
### 🔄 v2.0.0
|
|
||||||
- ✅ **Automatic Game Update System** - Smart version checking and seamless updates
|
|
||||||
- ✅ **Partial Automatic Launcher Update System** - This will inform you when I release a new update.
|
|
||||||
- 🛡️ **UserData Preservation** - Intelligent backup/restore of game saves during updates
|
|
||||||
- 🐧 **Enhanced Linux Support** - Full Wayland and X11 compatibility
|
|
||||||
- 🔄 **Multiplayer Auto-Install** - Automatic multiplayer client setup on updates (Windows)
|
|
||||||
- 📡 **API Integration** - Real-time version checking and client management
|
|
||||||
- 🎨 **UI Improvements** - Added contributor credits footer
|
|
||||||
- 🔄 **Complete Launcher Overhaul** - Total redesign of the launcher architecture and interface
|
|
||||||
- 🗂️ **Integrated Mod Manager** - Built-in mod installation, management
|
|
||||||
- 💬 **Community Chat System** - Real-time chat for launcher users to connect and communicate
|
|
||||||
|
|
||||||
### 🔧 v1.0.1
|
|
||||||
- 📁 **Custom Installation** - Choose installation directory with file browser
|
|
||||||
- 🏠 **Always on Top** - Launcher stays visible during installation
|
|
||||||
- 🧠 **Smart Detection** - Automatic game detection and UI adaptation
|
|
||||||
- 🗑️ **Uninstall Feature** - Easy game removal with one click
|
|
||||||
- 🔄 **Dynamic UI** - "INSTALL" vs "PLAY" button based on game state
|
|
||||||
- 🛠️ **Path Management** - Proper custom directory handling
|
|
||||||
- 💫 **UI Polish** - Improved layout and overflow prevention
|
|
||||||
|
|
||||||
### 🎉 v1.0.0 *(Initial Release)*
|
|
||||||
- 🎮 **Offline Gameplay** - Play Hytale without internet connection
|
|
||||||
- ⚡ **Auto Installation** - One-click game setup
|
|
||||||
- ☕ **Java Management** - Automatic Java runtime handling
|
|
||||||
- 🎨 **Modern Interface** - Clean, intuitive design
|
|
||||||
- 🌟 **First Release** - Core launcher functionality
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👥 Contributors
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
**Made with ❤️ by the community**
|
|
||||||
|
|
||||||
[](https://github.com/amiayweb/Hytale-F2P/graphs/contributors)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### 🏆 Project Creator
|
|
||||||
- [**@amiayweb**](https://github.com/amiayweb) - *Lead Developer & Project Creator*
|
|
||||||
- [**@Relyz1993**](https://github.com/Relyz1993) - *Server Helper & Second Developer & Project Creator*
|
|
||||||
|
|
||||||
### 🌟 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*
|
|
||||||
- [**@crimera**](https://github.com/crimera) - *Issues fixer*
|
|
||||||
- [**@Citeli-py**](https://github.com/Citeli-py) - *Issues fixer*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 GitHub Stats
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
**Need help?** Join us: https://discord.gg/gME8rUy3MB
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚖️ Legal Disclaimer
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
⚠️ **Important Notice** ⚠️
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
This launcher is created for **educational purposes only**.
|
|
||||||
|
|
||||||
🏛️ **Not Official** - This is an independent fan project **not affiliated with, endorsed by, or associated with** Hypixel Studios or Hytale.
|
|
||||||
|
|
||||||
🛡️ **No Warranty** - This software is provided **"as is"** without any warranty of any kind.
|
|
||||||
|
|
||||||
📝 **Responsibility** - The authors take no responsibility for how this software is used.
|
|
||||||
|
|
||||||
🛑 **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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
**⭐ Star this project if you found it helpful! ⭐**
|
|
||||||
|
|
||||||
*Made with ❤️ by [@amiayweb](https://github.com/amiayweb) and the amazing community*
|
|
||||||
[](https://www.star-history.com/#amiayweb/Hytale-F2P&type=date&legend=top-left)
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
https://discord.gg/mzdfCJy5J
|
||||||
|
|
||||||
|
https://t.me/hf2p_og
|
||||||
|
|||||||
459
SERVER.md
459
SERVER.md
@@ -1,459 +0,0 @@
|
|||||||
# Hytale F2P Server Guide
|
|
||||||
|
|
||||||
Play with friends online! This guide covers both easy in-game hosting and advanced dedicated server setup.
|
|
||||||
|
|
||||||
DOWNLOAD SERVER FILES HERE: https://discord.gg/MEyWUxt77m
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 1: Playing with Friends (Online Play)
|
|
||||||
|
|
||||||
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` | `sanasol.ws` | Auth server domain |
|
|
||||||
| `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
|
|
||||||
./run_server.sh [OPTIONS]
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `--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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
<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
|
|
||||||
./run_server.sh --backup --backup-dir ./backups --backup-frequency 30
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Backup
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "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
|
|
||||||
./run_server.sh --bind 0.0.0.0:5521
|
|
||||||
```
|
|
||||||
|
|
||||||
### "Out of memory"
|
|
||||||
|
|
||||||
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`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker Deployment (Advanced)
|
|
||||||
|
|
||||||
For production servers, use Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name hytale-server \
|
|
||||||
-p 5520:5520/udp \
|
|
||||||
-v ./data:/data \
|
|
||||||
-e HYTALE_AUTH_DOMAIN=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
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
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() {
|
|
||||||
// Enable dev mode for testing (reads dev-app-update.yml)
|
|
||||||
// Only enable in development, not in production builds
|
|
||||||
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
|
|
||||||
autoUpdater.forceDevUpdateConfig = true;
|
|
||||||
console.log('Dev update mode enabled - using dev-app-update.yml');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure logger for electron-updater
|
|
||||||
// Create a compatible logger interface
|
|
||||||
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: 'Auto-update requires code signing. 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;
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
|
|
||||||
// Default auth domain - can be overridden by env var or config
|
|
||||||
const DEFAULT_AUTH_DOMAIN = '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
|
|
||||||
function getAuthServerUrl() {
|
|
||||||
const domain = getAuthDomain();
|
|
||||||
return `https://sessions.${domain}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save auth domain to config
|
|
||||||
function saveAuthDomain(domain) {
|
|
||||||
saveConfig({ authDomain: domain || DEFAULT_AUTH_DOMAIN });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAppDir() {
|
|
||||||
const home = os.homedir();
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return path.join(home, 'AppData', 'Local', 'HytaleF2P');
|
|
||||||
} else if (process.platform === 'darwin') {
|
|
||||||
return path.join(home, 'Library', 'Application Support', 'HytaleF2P');
|
|
||||||
} else {
|
|
||||||
return path.join(home, '.hytalef2p');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONFIG_FILE = path.join(getAppDir(), 'config.json');
|
|
||||||
|
|
||||||
function loadConfig() {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(CONFIG_FILE)) {
|
|
||||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Notice: could not load config:', err.message);
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveConfig(update) {
|
|
||||||
try {
|
|
||||||
const configDir = path.dirname(CONFIG_FILE);
|
|
||||||
if (!fs.existsSync(configDir)) {
|
|
||||||
fs.mkdirSync(configDir, { recursive: true });
|
|
||||||
}
|
|
||||||
const config = loadConfig();
|
|
||||||
const next = { ...config, ...update };
|
|
||||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf8');
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Notice: could not save config:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveUsername(username) {
|
|
||||||
saveConfig({ username: username || 'Player' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadUsername() {
|
|
||||||
const config = loadConfig();
|
|
||||||
return config.username || 'Player';
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveChatUsername(chatUsername) {
|
|
||||||
saveConfig({ chatUsername: chatUsername || '' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadChatUsername() {
|
|
||||||
const config = loadConfig();
|
|
||||||
return config.chatUsername || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUuidForUser(username) {
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
const config = loadConfig();
|
|
||||||
const userUuids = config.userUuids || {};
|
|
||||||
|
|
||||||
if (userUuids[username]) {
|
|
||||||
return userUuids[username];
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUuid = uuidv4();
|
|
||||||
userUuids[username] = newUuid;
|
|
||||||
saveConfig({ userUuids });
|
|
||||||
|
|
||||||
return newUuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveJavaPath(javaPath) {
|
|
||||||
const trimmed = (javaPath || '').trim();
|
|
||||||
saveConfig({ javaPath: trimmed });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadJavaPath() {
|
|
||||||
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 || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveInstallPath(installPath) {
|
|
||||||
const trimmed = (installPath || '').trim();
|
|
||||||
saveConfig({ installPath: trimmed });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadInstallPath() {
|
|
||||||
const config = loadConfig();
|
|
||||||
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 saveModsToConfig(mods) {
|
|
||||||
try {
|
|
||||||
const config = loadConfig();
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
if (!fs.existsSync(configDir)) {
|
|
||||||
fs.mkdirSync(configDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
||||||
console.log('Mods saved to config.json');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving mods to config:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadModsFromConfig() {
|
|
||||||
try {
|
|
||||||
const config = loadConfig();
|
|
||||||
|
|
||||||
// Prefer Active Profile
|
|
||||||
if (config.activeProfileId && config.profiles && config.profiles[config.activeProfileId]) {
|
|
||||||
return config.profiles[config.activeProfileId].mods || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return config.installedMods || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading mods from config:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFirstLaunch() {
|
|
||||||
const config = loadConfig();
|
|
||||||
|
|
||||||
if ('hasLaunchedBefore' in config) {
|
|
||||||
return !config.hasLaunchedBefore;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUserData = config.installPath || config.username || config.javaPath ||
|
|
||||||
config.chatUsername || config.userUuids ||
|
|
||||||
Object.keys(config).length > 0;
|
|
||||||
|
|
||||||
if (!hasUserData) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function markAsLaunched() {
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
loadConfig,
|
|
||||||
saveConfig,
|
|
||||||
saveUsername,
|
|
||||||
loadUsername,
|
|
||||||
saveChatUsername,
|
|
||||||
loadChatUsername,
|
|
||||||
saveChatColor,
|
|
||||||
loadChatColor,
|
|
||||||
getUuidForUser,
|
|
||||||
saveJavaPath,
|
|
||||||
loadJavaPath,
|
|
||||||
saveInstallPath,
|
|
||||||
loadInstallPath,
|
|
||||||
saveDiscordRPC,
|
|
||||||
loadDiscordRPC,
|
|
||||||
saveLanguage,
|
|
||||||
loadLanguage,
|
|
||||||
saveModsToConfig,
|
|
||||||
loadModsFromConfig,
|
|
||||||
isFirstLaunch,
|
|
||||||
markAsLaunched,
|
|
||||||
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
|
|
||||||
};
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
function getAppDir() {
|
|
||||||
const home = os.homedir();
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return path.join(home, 'AppData', 'Local', 'HytaleF2P');
|
|
||||||
} else if (process.platform === 'darwin') {
|
|
||||||
return path.join(home, 'Library', 'Application Support', 'HytaleF2P');
|
|
||||||
} else {
|
|
||||||
return path.join(home, '.hytalef2p');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_APP_DIR = getAppDir();
|
|
||||||
|
|
||||||
function getResolvedAppDir(customPath) {
|
|
||||||
if (customPath && customPath.trim()) {
|
|
||||||
return path.join(customPath.trim(), 'HytaleF2P');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const configFile = path.join(DEFAULT_APP_DIR, 'config.json');
|
|
||||||
if (fs.existsSync(configFile)) {
|
|
||||||
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
||||||
if (config.installPath && config.installPath.trim()) {
|
|
||||||
return path.join(config.installPath.trim(), 'HytaleF2P');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
return DEFAULT_APP_DIR;
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandHome(inputPath) {
|
|
||||||
if (!inputPath) {
|
|
||||||
return inputPath;
|
|
||||||
}
|
|
||||||
if (inputPath === '~') {
|
|
||||||
return os.homedir();
|
|
||||||
}
|
|
||||||
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
|
|
||||||
return path.join(os.homedir(), inputPath.slice(2));
|
|
||||||
}
|
|
||||||
return inputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const APP_DIR = DEFAULT_APP_DIR;
|
|
||||||
const CACHE_DIR = path.join(APP_DIR, 'cache');
|
|
||||||
const TOOLS_DIR = path.join(APP_DIR, 'butler');
|
|
||||||
const GAME_DIR = path.join(APP_DIR, 'release', 'package', 'game', 'latest');
|
|
||||||
const JRE_DIR = path.join(APP_DIR, 'release', 'package', 'jre', 'latest');
|
|
||||||
const PLAYER_ID_FILE = path.join(APP_DIR, 'player_id.json');
|
|
||||||
|
|
||||||
function getClientCandidates(gameLatest) {
|
|
||||||
const candidates = [];
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
candidates.push(path.join(gameLatest, 'Client', 'HytaleClient.exe'));
|
|
||||||
} else if (process.platform === 'darwin') {
|
|
||||||
candidates.push(path.join(gameLatest, 'Client', 'Hytale.app', 'Contents', 'MacOS', 'HytaleClient'));
|
|
||||||
candidates.push(path.join(gameLatest, 'Client', 'HytaleClient'));
|
|
||||||
} else {
|
|
||||||
candidates.push(path.join(gameLatest, 'Client', 'HytaleClient'));
|
|
||||||
}
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findClientPath(gameLatest) {
|
|
||||||
const candidates = getClientCandidates(gameLatest);
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (fs.existsSync(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findUserDataPath(gameLatest) {
|
|
||||||
const candidates = [];
|
|
||||||
|
|
||||||
candidates.push(path.join(gameLatest, 'Client', '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, 'UserData'));
|
|
||||||
|
|
||||||
candidates.push(path.join(gameLatest, 'Client', 'UserData'));
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (fs.existsSync(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let defaultPath;
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
defaultPath = path.join(gameLatest, 'Client', 'UserData');
|
|
||||||
} else {
|
|
||||||
defaultPath = path.join(gameLatest, 'Client', 'UserData');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(defaultPath)) {
|
|
||||||
fs.mkdirSync(defaultPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findUserDataRecursive(gameLatest) {
|
|
||||||
function searchDirectory(dir) {
|
|
||||||
try {
|
|
||||||
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.isDirectory()) {
|
|
||||||
const fullPath = path.join(dir, item.name);
|
|
||||||
|
|
||||||
if (item.name === 'UserData') {
|
|
||||||
return fullPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const found = searchDirectory(fullPath);
|
|
||||||
if (found) {
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(gameLatest)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const found = searchDirectory(gameLatest);
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getModsPath(customInstallPath = null) {
|
|
||||||
try {
|
|
||||||
let installPath = customInstallPath;
|
|
||||||
|
|
||||||
if (!installPath) {
|
|
||||||
const configFile = path.join(DEFAULT_APP_DIR, 'config.json');
|
|
||||||
if (fs.existsSync(configFile)) {
|
|
||||||
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
||||||
installPath = config.installPath || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!installPath) {
|
|
||||||
// Use the standard app directory logic which handles platforms correctly
|
|
||||||
installPath = getAppDir();
|
|
||||||
}
|
|
||||||
|
|
||||||
const gameLatest = path.join(installPath, 'release', 'package', 'game', 'latest');
|
|
||||||
|
|
||||||
const userDataPath = findUserDataPath(gameLatest);
|
|
||||||
|
|
||||||
const modsPath = path.join(userDataPath, 'Mods');
|
|
||||||
const disabledModsPath = path.join(userDataPath, 'DisabledMods');
|
|
||||||
const profilesPath = path.join(userDataPath, 'Profiles');
|
|
||||||
|
|
||||||
if (!fs.existsSync(modsPath)) {
|
|
||||||
// Ensure the Mods directory exists
|
|
||||||
fs.mkdirSync(modsPath, { recursive: true });
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(disabledModsPath)) {
|
|
||||||
fs.mkdirSync(disabledModsPath, { recursive: true });
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(profilesPath)) {
|
|
||||||
fs.mkdirSync(profilesPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return modsPath;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting mods path:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProfilesDir(customInstallPath = null) {
|
|
||||||
try {
|
|
||||||
// get UserData path
|
|
||||||
let installPath = customInstallPath;
|
|
||||||
if (!installPath) {
|
|
||||||
const configFile = path.join(DEFAULT_APP_DIR, 'config.json');
|
|
||||||
if (fs.existsSync(configFile)) {
|
|
||||||
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
||||||
installPath = config.installPath || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!installPath) installPath = getAppDir();
|
|
||||||
|
|
||||||
const gameLatest = path.join(installPath, 'release', 'package', 'game', 'latest');
|
|
||||||
const userDataPath = findUserDataPath(gameLatest);
|
|
||||||
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 = {
|
|
||||||
getAppDir,
|
|
||||||
getResolvedAppDir,
|
|
||||||
expandHome,
|
|
||||||
APP_DIR,
|
|
||||||
CACHE_DIR,
|
|
||||||
TOOLS_DIR,
|
|
||||||
GAME_DIR,
|
|
||||||
JRE_DIR,
|
|
||||||
PLAYER_ID_FILE,
|
|
||||||
getClientCandidates,
|
|
||||||
findClientPath,
|
|
||||||
findUserDataPath,
|
|
||||||
findUserDataRecursive,
|
|
||||||
getModsPath,
|
|
||||||
getProfilesDir
|
|
||||||
};
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
// Main launcher module - orchestrates all launcher functionality
|
|
||||||
// This file serves as the main entry point and re-exports all necessary functions
|
|
||||||
|
|
||||||
// Core modules
|
|
||||||
const {
|
|
||||||
saveUsername,
|
|
||||||
loadUsername,
|
|
||||||
saveChatUsername,
|
|
||||||
loadChatUsername,
|
|
||||||
saveChatColor,
|
|
||||||
loadChatColor,
|
|
||||||
saveJavaPath,
|
|
||||||
loadJavaPath,
|
|
||||||
saveInstallPath,
|
|
||||||
loadInstallPath,
|
|
||||||
saveDiscordRPC,
|
|
||||||
loadDiscordRPC,
|
|
||||||
saveLanguage,
|
|
||||||
loadLanguage,
|
|
||||||
saveCloseLauncherOnStart,
|
|
||||||
loadCloseLauncherOnStart,
|
|
||||||
saveModsToConfig,
|
|
||||||
loadModsFromConfig,
|
|
||||||
getUuidForUser,
|
|
||||||
isFirstLaunch,
|
|
||||||
markAsLaunched,
|
|
||||||
// UUID Management
|
|
||||||
getCurrentUuid,
|
|
||||||
getAllUuidMappings,
|
|
||||||
setUuidForUser,
|
|
||||||
generateNewUuid,
|
|
||||||
deleteUuidForUser,
|
|
||||||
resetCurrentUserUuid,
|
|
||||||
// GPU Preference
|
|
||||||
saveGpuPreference,
|
|
||||||
loadGpuPreference
|
|
||||||
} = require('./core/config');
|
|
||||||
|
|
||||||
const { getResolvedAppDir, getModsPath } = require('./core/paths');
|
|
||||||
|
|
||||||
// Managers
|
|
||||||
const {
|
|
||||||
isGameInstalled,
|
|
||||||
installGame,
|
|
||||||
uninstallGame,
|
|
||||||
updateGameFiles,
|
|
||||||
checkExistingGameInstallation,
|
|
||||||
repairGame
|
|
||||||
} = require('./managers/gameManager');
|
|
||||||
|
|
||||||
const {
|
|
||||||
launchGame,
|
|
||||||
launchGameWithVersionCheck
|
|
||||||
} = require('./managers/gameLauncher');
|
|
||||||
|
|
||||||
const { getJavaDetection } = require('./managers/javaManager');
|
|
||||||
|
|
||||||
const {
|
|
||||||
downloadAndReplaceHomePageUI,
|
|
||||||
findHomePageUIPath,
|
|
||||||
downloadAndReplaceLogo,
|
|
||||||
findLogoPath
|
|
||||||
} = require('./managers/uiFileManager');
|
|
||||||
|
|
||||||
const {
|
|
||||||
loadInstalledMods,
|
|
||||||
downloadMod,
|
|
||||||
uninstallMod,
|
|
||||||
toggleMod
|
|
||||||
} = require('./managers/modManager');
|
|
||||||
|
|
||||||
// Services
|
|
||||||
const {
|
|
||||||
getInstalledClientVersion,
|
|
||||||
getLatestClientVersion
|
|
||||||
} = require('./services/versionManager');
|
|
||||||
|
|
||||||
const { getHytaleNews } = require('./services/newsManager');
|
|
||||||
|
|
||||||
const { getOrCreatePlayerId } = require('./services/playerManager');
|
|
||||||
|
|
||||||
const {
|
|
||||||
proposeGameUpdate,
|
|
||||||
handleFirstLaunchCheck
|
|
||||||
} = require('./services/firstLaunch');
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
const { detectGpu } = require('./utils/platformUtils');
|
|
||||||
|
|
||||||
// Re-export all functions to maintain backward compatibility
|
|
||||||
module.exports = {
|
|
||||||
// Game launch functions
|
|
||||||
launchGame,
|
|
||||||
launchGameWithVersionCheck,
|
|
||||||
|
|
||||||
// Game installation functions
|
|
||||||
installGame,
|
|
||||||
isGameInstalled,
|
|
||||||
uninstallGame,
|
|
||||||
updateGameFiles,
|
|
||||||
repairGame,
|
|
||||||
|
|
||||||
// User configuration functions
|
|
||||||
saveUsername,
|
|
||||||
loadUsername,
|
|
||||||
saveChatUsername,
|
|
||||||
loadChatUsername,
|
|
||||||
saveChatColor,
|
|
||||||
loadChatColor,
|
|
||||||
getUuidForUser,
|
|
||||||
|
|
||||||
// Java configuration functions
|
|
||||||
saveJavaPath,
|
|
||||||
loadJavaPath,
|
|
||||||
getJavaDetection,
|
|
||||||
|
|
||||||
// Installation path functions
|
|
||||||
saveInstallPath,
|
|
||||||
loadInstallPath,
|
|
||||||
|
|
||||||
// Discord RPC functions
|
|
||||||
saveDiscordRPC,
|
|
||||||
loadDiscordRPC,
|
|
||||||
|
|
||||||
// Language functions
|
|
||||||
saveLanguage,
|
|
||||||
loadLanguage,
|
|
||||||
|
|
||||||
// Close Launcher functions
|
|
||||||
saveCloseLauncherOnStart,
|
|
||||||
loadCloseLauncherOnStart,
|
|
||||||
|
|
||||||
// GPU Preference functions
|
|
||||||
saveGpuPreference,
|
|
||||||
loadGpuPreference,
|
|
||||||
detectGpu,
|
|
||||||
|
|
||||||
// Version functions
|
|
||||||
getInstalledClientVersion,
|
|
||||||
getLatestClientVersion,
|
|
||||||
|
|
||||||
// News functions
|
|
||||||
getHytaleNews,
|
|
||||||
|
|
||||||
// Player ID functions
|
|
||||||
getOrCreatePlayerId,
|
|
||||||
|
|
||||||
// UUID Management functions
|
|
||||||
getCurrentUuid,
|
|
||||||
getAllUuidMappings,
|
|
||||||
setUuidForUser,
|
|
||||||
generateNewUuid,
|
|
||||||
deleteUuidForUser,
|
|
||||||
resetCurrentUserUuid,
|
|
||||||
|
|
||||||
// Mod management functions
|
|
||||||
getModsPath,
|
|
||||||
loadInstalledMods,
|
|
||||||
downloadMod,
|
|
||||||
uninstallMod,
|
|
||||||
toggleMod,
|
|
||||||
saveModsToConfig,
|
|
||||||
loadModsFromConfig,
|
|
||||||
|
|
||||||
// UI file management functions
|
|
||||||
downloadAndReplaceHomePageUI,
|
|
||||||
findHomePageUIPath,
|
|
||||||
downloadAndReplaceLogo,
|
|
||||||
findLogoPath,
|
|
||||||
|
|
||||||
// First launch functions
|
|
||||||
isFirstLaunch,
|
|
||||||
markAsLaunched,
|
|
||||||
checkExistingGameInstallation,
|
|
||||||
proposeGameUpdate,
|
|
||||||
handleFirstLaunchCheck,
|
|
||||||
|
|
||||||
// Path functions
|
|
||||||
getResolvedAppDir
|
|
||||||
};
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
class Logger {
|
|
||||||
constructor() {
|
|
||||||
this.logDir = null;
|
|
||||||
this.logFile = null;
|
|
||||||
this.maxLogSize = 10 * 1024 * 1024; // 10MB
|
|
||||||
this.maxLogFiles = 5;
|
|
||||||
this.originalConsole = {
|
|
||||||
log: console.log,
|
|
||||||
error: console.error,
|
|
||||||
warn: console.warn,
|
|
||||||
info: console.info
|
|
||||||
};
|
|
||||||
|
|
||||||
this.initializeLogDirectory();
|
|
||||||
}
|
|
||||||
|
|
||||||
getAppDir() {
|
|
||||||
const home = os.homedir();
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return path.join(home, 'AppData', 'Local', 'HytaleF2P');
|
|
||||||
} else if (process.platform === 'darwin') {
|
|
||||||
return path.join(home, 'Library', 'Application Support', 'HytaleF2P');
|
|
||||||
} else {
|
|
||||||
return path.join(home, '.hytalef2p');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getInstallPath() {
|
|
||||||
try {
|
|
||||||
const configFile = path.join(this.getAppDir(), 'config.json');
|
|
||||||
if (fs.existsSync(configFile)) {
|
|
||||||
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
||||||
if (config.installPath && config.installPath.trim()) {
|
|
||||||
return path.join(config.installPath.trim(), 'HytaleF2P');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
return this.getAppDir();
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeLogDirectory() {
|
|
||||||
try {
|
|
||||||
const installPath = this.getInstallPath();
|
|
||||||
this.logDir = path.join(installPath, 'logs');
|
|
||||||
|
|
||||||
if (!fs.existsSync(this.logDir)) {
|
|
||||||
fs.mkdirSync(this.logDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
const dateString = today.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
||||||
const timeString = today.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-'); // HH-MM-SS
|
|
||||||
this.logFile = path.join(this.logDir, `launcher-${dateString}-${timeString}.log`);
|
|
||||||
|
|
||||||
this.writeToFile(`\n=== NEW LAUNCHER SESSION - ${today.toISOString()} ===\n`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logDir = path.join(os.tmpdir(), 'HytaleF2P-logs');
|
|
||||||
if (!fs.existsSync(this.logDir)) {
|
|
||||||
fs.mkdirSync(this.logDir, { recursive: true });
|
|
||||||
}
|
|
||||||
const today = new Date();
|
|
||||||
const dateString = today.toISOString().split('T')[0];
|
|
||||||
const timeString = today.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-');
|
|
||||||
this.logFile = path.join(this.logDir, `launcher-${dateString}-${timeString}.log`);
|
|
||||||
this.writeToFile(`\n=== FALLBACK SESSION IN TEMP - ${today.toISOString()} ===\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeToFile(message) {
|
|
||||||
if (!this.logFile) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(this.logFile)) {
|
|
||||||
const stats = fs.statSync(this.logFile);
|
|
||||||
if (stats.size > this.maxLogSize) {
|
|
||||||
this.rotateLogFile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.appendFileSync(this.logFile, message, 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
this.originalConsole.error('Impossible d\'écrire dans le fichier de log:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rotateLogFile() {
|
|
||||||
try {
|
|
||||||
const today = new Date();
|
|
||||||
const dateString = today.toISOString().split('T')[0];
|
|
||||||
const timeString = today.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-');
|
|
||||||
|
|
||||||
const rotatedFile = path.join(this.logDir, `launcher-${dateString}-${timeString}.log`);
|
|
||||||
fs.renameSync(this.logFile, rotatedFile);
|
|
||||||
|
|
||||||
this.cleanupOldLogs();
|
|
||||||
|
|
||||||
const newToday = new Date();
|
|
||||||
const newDateString = newToday.toISOString().split('T')[0];
|
|
||||||
const newTimeString = newToday.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-');
|
|
||||||
this.logFile = path.join(this.logDir, `launcher-${newDateString}-${newTimeString}.log`);
|
|
||||||
this.writeToFile(`\n=== LOG ROTATION - ${newToday.toISOString()} ===\n`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.originalConsole.error('Erreur lors de la rotation des logs:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupOldLogs() {
|
|
||||||
try {
|
|
||||||
const files = fs.readdirSync(this.logDir)
|
|
||||||
.filter(file => file.startsWith('launcher-') && file.endsWith('.log'))
|
|
||||||
.map(file => ({
|
|
||||||
name: file,
|
|
||||||
path: path.join(this.logDir, file),
|
|
||||||
mtime: fs.statSync(path.join(this.logDir, file)).mtime
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.mtime - a.mtime);
|
|
||||||
|
|
||||||
if (files.length > this.maxLogFiles) {
|
|
||||||
const filesToDelete = files.slice(this.maxLogFiles);
|
|
||||||
filesToDelete.forEach(file => {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(file.path);
|
|
||||||
} catch (err) {
|
|
||||||
this.originalConsole.error(`Impossible de supprimer le fichier de log ${file.name}:`, err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.originalConsole.error('Erreur lors du nettoyage des logs:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatLogMessage(level, ...args) {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const message = args.map(arg => {
|
|
||||||
if (typeof arg === 'object') {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(arg, null, 2);
|
|
||||||
} catch (e) {
|
|
||||||
return String(arg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return String(arg);
|
|
||||||
}).join(' ');
|
|
||||||
|
|
||||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
log(...args) {
|
|
||||||
const logMessage = this.formatLogMessage('info', ...args);
|
|
||||||
this.writeToFile(logMessage);
|
|
||||||
this.originalConsole.log(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
error(...args) {
|
|
||||||
const logMessage = this.formatLogMessage('error', ...args);
|
|
||||||
this.writeToFile(logMessage);
|
|
||||||
this.originalConsole.error(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
warn(...args) {
|
|
||||||
const logMessage = this.formatLogMessage('warn', ...args);
|
|
||||||
this.writeToFile(logMessage);
|
|
||||||
this.originalConsole.warn(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
info(...args) {
|
|
||||||
const logMessage = this.formatLogMessage('info', ...args);
|
|
||||||
this.writeToFile(logMessage);
|
|
||||||
this.originalConsole.info(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
interceptConsole() {
|
|
||||||
console.log = (...args) => this.log(...args);
|
|
||||||
console.error = (...args) => this.error(...args);
|
|
||||||
console.warn = (...args) => this.warn(...args);
|
|
||||||
console.info = (...args) => this.info(...args);
|
|
||||||
|
|
||||||
process.on('uncaughtException', (error) => {
|
|
||||||
this.error('Uncaught exception:', error.stack || error.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
|
||||||
this.error('Unhandled rejection at', promise, 'reason:', reason);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreConsole() {
|
|
||||||
console.log = this.originalConsole.log;
|
|
||||||
console.error = this.originalConsole.error;
|
|
||||||
console.warn = this.originalConsole.warn;
|
|
||||||
console.info = this.originalConsole.info;
|
|
||||||
}
|
|
||||||
|
|
||||||
getLogDirectory() {
|
|
||||||
return this.logDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInstallPath() {
|
|
||||||
this.initializeLogDirectory();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const logger = new Logger();
|
|
||||||
|
|
||||||
module.exports = logger;
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const AdmZip = require('adm-zip');
|
|
||||||
const { TOOLS_DIR } = require('../core/paths');
|
|
||||||
const { getOS, getArch } = require('../utils/platformUtils');
|
|
||||||
const { downloadFile } = require('../utils/fileManager');
|
|
||||||
|
|
||||||
async function installButler(toolsDir = TOOLS_DIR) {
|
|
||||||
if (!fs.existsSync(toolsDir)) {
|
|
||||||
fs.mkdirSync(toolsDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const butlerName = process.platform === 'win32' ? 'butler.exe' : 'butler';
|
|
||||||
const butlerPath = path.join(toolsDir, butlerName);
|
|
||||||
const zipPath = path.join(toolsDir, 'butler.zip');
|
|
||||||
|
|
||||||
if (fs.existsSync(butlerPath)) {
|
|
||||||
return butlerPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
let urls = [];
|
|
||||||
const osName = getOS();
|
|
||||||
const arch = getArch();
|
|
||||||
if (osName === 'windows') {
|
|
||||||
urls = ['https://broth.itch.zone/butler/windows-amd64/LATEST/archive/default'];
|
|
||||||
} else if (osName === 'darwin') {
|
|
||||||
if (arch === 'arm64') {
|
|
||||||
urls = [
|
|
||||||
'https://broth.itch.zone/butler/darwin-arm64/LATEST/archive/default',
|
|
||||||
'https://broth.itch.zone/butler/darwin-amd64/LATEST/archive/default'
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
urls = ['https://broth.itch.zone/butler/darwin-amd64/LATEST/archive/default'];
|
|
||||||
}
|
|
||||||
} else if (osName === 'linux') {
|
|
||||||
urls = ['https://broth.itch.zone/butler/linux-amd64/LATEST/archive/default'];
|
|
||||||
} else {
|
|
||||||
throw new Error('Operating system not supported');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Fetching Butler tool...');
|
|
||||||
let lastError = null;
|
|
||||||
for (const url of urls) {
|
|
||||||
try {
|
|
||||||
await downloadFile(url, zipPath);
|
|
||||||
lastError = null;
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lastError) {
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Unpacking Butler...');
|
|
||||||
const zip = new AdmZip(zipPath);
|
|
||||||
zip.extractAllTo(toolsDir, true);
|
|
||||||
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
fs.chmodSync(butlerPath, 0o755);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(zipPath);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Notice: could not delete butler.zip');
|
|
||||||
}
|
|
||||||
|
|
||||||
return butlerPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
installButler
|
|
||||||
};
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const { exec } = require('child_process');
|
|
||||||
const { promisify } = require('util');
|
|
||||||
const { spawn } = require('child_process');
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
const { getResolvedAppDir, findClientPath } = require('../core/paths');
|
|
||||||
const { setupWaylandEnvironment, setupGpuEnvironment } = require('../utils/platformUtils');
|
|
||||||
const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser, getAuthServerUrl, getAuthDomain } = require('../core/config');
|
|
||||||
const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager');
|
|
||||||
const { getInstalledClientVersion, getLatestClientVersion } = require('../services/versionManager');
|
|
||||||
const { updateGameFiles } = require('./gameManager');
|
|
||||||
const { syncModsForCurrentProfile } = require('./modManager');
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// 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') {
|
|
||||||
const customAppDir = getResolvedAppDir(installPathOverride);
|
|
||||||
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
|
|
||||||
const customJreDir = path.join(customAppDir, 'release', 'package', 'jre', 'latest');
|
|
||||||
const userDataDir = path.join(customGameDir, 'Client', 'UserData');
|
|
||||||
|
|
||||||
const gameLatest = customGameDir;
|
|
||||||
let clientPath = findClientPath(gameLatest);
|
|
||||||
|
|
||||||
if (!clientPath) {
|
|
||||||
throw new Error('Game is not installed. Please install the game first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
saveUsername(playerName);
|
|
||||||
if (installPathOverride) {
|
|
||||||
saveInstallPath(installPathOverride);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuredJava = (javaPathOverride !== undefined && javaPathOverride !== null
|
|
||||||
? javaPathOverride
|
|
||||||
: loadJavaPath() || '').trim();
|
|
||||||
let javaBin = null;
|
|
||||||
|
|
||||||
if (configuredJava) {
|
|
||||||
javaBin = await resolveJavaPath(configuredJava);
|
|
||||||
if (!javaBin) {
|
|
||||||
throw new Error(`Configured Java path not found: ${configuredJava}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
javaBin = getJavaExec(customJreDir);
|
|
||||||
|
|
||||||
if (!getBundledJavaPath(customJreDir)) {
|
|
||||||
const fallback = await detectSystemJava();
|
|
||||||
if (fallback) {
|
|
||||||
javaBin = fallback;
|
|
||||||
} else {
|
|
||||||
throw new Error('Java runtime not found. Please install the game first or configure Java path.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
const authDomain = getAuthDomain();
|
|
||||||
if (clientPatcher) {
|
|
||||||
try {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Patching game for custom server...', null, null, null, null);
|
|
||||||
}
|
|
||||||
console.log(`Patching game binaries for ${authDomain}...`);
|
|
||||||
|
|
||||||
const patchResult = await clientPatcher.ensureClientPatched(gameLatest, (msg, percent) => {
|
|
||||||
console.log(`[Patcher] ${msg}`);
|
|
||||||
if (progressCallback && msg) {
|
|
||||||
progressCallback(msg, percent, null, null, null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (patchResult.success) {
|
|
||||||
if (patchResult.alreadyPatched) {
|
|
||||||
console.log(`Game already patched for ${authDomain}`);
|
|
||||||
} else {
|
|
||||||
console.log(`Game patched successfully (${patchResult.patchCount} total occurrences)`);
|
|
||||||
if (patchResult.client) {
|
|
||||||
console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`);
|
|
||||||
}
|
|
||||||
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') {
|
|
||||||
try {
|
|
||||||
const appBundle = path.join(gameLatest, 'Client', 'Hytale.app');
|
|
||||||
const serverDir = path.join(gameLatest, 'Server');
|
|
||||||
|
|
||||||
const signPath = async (targetPath, deep = false) => {
|
|
||||||
await execAsync(`xattr -cr "${targetPath}"`).catch(() => { });
|
|
||||||
const deepFlag = deep ? '--deep ' : '';
|
|
||||||
await execAsync(`codesign --force ${deepFlag}--sign - "${targetPath}"`).catch(() => { });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fs.existsSync(appBundle)) {
|
|
||||||
await signPath(appBundle, true);
|
|
||||||
console.log('Signed macOS app bundle (after patching)');
|
|
||||||
} else {
|
|
||||||
await signPath(path.dirname(clientPath), true);
|
|
||||||
console.log('Signed macOS client binary (after patching)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (javaBin && fs.existsSync(javaBin)) {
|
|
||||||
let jreRoot = path.dirname(path.dirname(javaBin));
|
|
||||||
if (jreRoot.endsWith('Home')) {
|
|
||||||
jreRoot = path.dirname(path.dirname(jreRoot));
|
|
||||||
}
|
|
||||||
await signPath(jreRoot, true);
|
|
||||||
await signPath(javaBin, false);
|
|
||||||
console.log('Signed Java runtime');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(serverDir)) {
|
|
||||||
await execAsync(`xattr -cr "${serverDir}"`).catch(() => { });
|
|
||||||
await execAsync(`find "${serverDir}" -type f -perm +111 -exec codesign --force --sign - {} \\;`).catch(() => { });
|
|
||||||
console.log('Signed server binaries (after patching)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (javaBin && fs.existsSync(javaBin)) {
|
|
||||||
const javaWrapperPath = path.join(path.dirname(javaBin), 'java-wrapper');
|
|
||||||
const wrapperScript = `#!/bin/bash
|
|
||||||
# Java wrapper for macOS - adds --disable-sentry to fix Sentry hang issue
|
|
||||||
REAL_JAVA="${javaBin}"
|
|
||||||
ARGS=("$@")
|
|
||||||
for i in "\${!ARGS[@]}"; do
|
|
||||||
if [[ "\${ARGS[$i]}" == *"HytaleServer.jar"* ]]; then
|
|
||||||
ARGS=("\${ARGS[@]:0:$((i+1))}" "--disable-sentry" "\${ARGS[@]:$((i+1))}")
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
exec "$REAL_JAVA" "\${ARGS[@]}"
|
|
||||||
`;
|
|
||||||
fs.writeFileSync(javaWrapperPath, wrapperScript, { mode: 0o755 });
|
|
||||||
await signPath(javaWrapperPath, false);
|
|
||||||
console.log('Created java wrapper with --disable-sentry fix');
|
|
||||||
javaBin = javaWrapperPath;
|
|
||||||
}
|
|
||||||
} catch (signError) {
|
|
||||||
console.log('Notice: macOS signing step failed:', signError.message);
|
|
||||||
console.log('The game may still launch if Gatekeeper allows it');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'--app-dir', gameLatest,
|
|
||||||
'--java-exec', javaBin,
|
|
||||||
'--auth-mode', 'authenticated',
|
|
||||||
'--uuid', uuid,
|
|
||||||
'--name', playerName,
|
|
||||||
'--identity-token', identityToken,
|
|
||||||
'--session-token', sessionToken,
|
|
||||||
'--user-dir', userDataDir
|
|
||||||
];
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
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(`Command: "${clientPath}" ${args.join(' ')}`);
|
|
||||||
|
|
||||||
const env = { ...process.env };
|
|
||||||
|
|
||||||
const waylandEnv = setupWaylandEnvironment();
|
|
||||||
Object.assign(env, waylandEnv);
|
|
||||||
|
|
||||||
const gpuEnv = setupGpuEnvironment(gpuPreference);
|
|
||||||
Object.assign(env, gpuEnv);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let spawnOptions = {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
detached: true,
|
|
||||||
env: env
|
|
||||||
};
|
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
spawnOptions.shell = false;
|
|
||||||
spawnOptions.windowsHide = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = spawn(clientPath, args, spawnOptions);
|
|
||||||
|
|
||||||
console.log(`Game process started with PID: ${child.pid}`);
|
|
||||||
|
|
||||||
let hasExited = false;
|
|
||||||
let outputReceived = false;
|
|
||||||
|
|
||||||
child.stdout.on('data', (data) => {
|
|
||||||
outputReceived = true;
|
|
||||||
console.log(`Game output: ${data.toString().trim()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr.on('data', (data) => {
|
|
||||||
outputReceived = true;
|
|
||||||
console.error(`Game error: ${data.toString().trim()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', (error) => {
|
|
||||||
hasExited = true;
|
|
||||||
console.error(`Failed to start game process: ${error.message}`);
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback(`Failed to start game: ${error.message}`, -1, null, null, null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('exit', (code, signal) => {
|
|
||||||
hasExited = true;
|
|
||||||
if (code !== null) {
|
|
||||||
console.log(`Game process exited with code ${code}`);
|
|
||||||
if (code !== 0 && progressCallback) {
|
|
||||||
progressCallback(`Game exited with error code ${code}`, -1, null, null, null);
|
|
||||||
}
|
|
||||||
} else if (signal) {
|
|
||||||
console.log(`Game process terminated by signal ${signal}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!hasExited) {
|
|
||||||
console.log('Game appears to be running successfully');
|
|
||||||
child.unref();
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Game launched successfully', 100, null, null, null);
|
|
||||||
}
|
|
||||||
} else if (!outputReceived) {
|
|
||||||
console.warn('Game process exited immediately with no output - possible issue with game files or dependencies');
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return { success: true, installed: true, launched: true, pid: child.pid };
|
|
||||||
} catch (spawnError) {
|
|
||||||
console.error(`Error spawning game process: ${spawnError.message}`);
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback(`Error launching game: ${spawnError.message}`, -1, null, null, null);
|
|
||||||
}
|
|
||||||
throw spawnError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launchGameWithVersionCheck(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto') {
|
|
||||||
try {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Checking for updates...', 0, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [installedVersion, latestVersion] = await Promise.all([
|
|
||||||
getInstalledClientVersion(),
|
|
||||||
getLatestClientVersion()
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log(`Installed version: ${installedVersion}, Latest version: ${latestVersion}`);
|
|
||||||
|
|
||||||
let needsUpdate = false;
|
|
||||||
if (installedVersion && latestVersion && installedVersion !== latestVersion) {
|
|
||||||
needsUpdate = true;
|
|
||||||
console.log('Version mismatch detected, update required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsUpdate) {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Game update required, starting update process...', 10, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const customAppDir = getResolvedAppDir(installPathOverride);
|
|
||||||
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
|
|
||||||
const customToolsDir = path.join(customAppDir, 'butler');
|
|
||||||
const customCacheDir = path.join(customAppDir, 'cache');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateGameFiles(latestVersion, progressCallback, customGameDir, customToolsDir, customCacheDir);
|
|
||||||
console.log('Game updated successfully, waiting before launch...');
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Preparing game launch...', 90, null, null, null);
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
||||||
|
|
||||||
} catch (updateError) {
|
|
||||||
console.error('Update failed:', updateError);
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback(`Update failed: ${updateError.message}`, -1, null, null, null);
|
|
||||||
}
|
|
||||||
throw updateError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Launching game...', 80, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await launchGame(playerName, progressCallback, javaPathOverride, installPathOverride, gpuPreference);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in version check and launch:', error);
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback(`Error: ${error.message}`, -1, null, null, null);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
launchGame,
|
|
||||||
launchGameWithVersionCheck
|
|
||||||
};
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { execFile } = require('child_process');
|
|
||||||
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
|
|
||||||
const { getOS, getArch } = require('../utils/platformUtils');
|
|
||||||
const { downloadFile } = require('../utils/fileManager');
|
|
||||||
const { getLatestClientVersion, getInstalledClientVersion } = require('../services/versionManager');
|
|
||||||
const { installButler } = require('./butlerManager');
|
|
||||||
const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFileManager');
|
|
||||||
const { saveUsername, saveInstallPath, loadJavaPath, CONFIG_FILE, loadConfig } = require('../core/config');
|
|
||||||
const { resolveJavaPath, detectSystemJava, downloadJRE, getJavaExec, getBundledJavaPath } = require('./javaManager');
|
|
||||||
|
|
||||||
async function downloadPWR(version = 'release', fileName = '4.pwr', progressCallback, cacheDir = CACHE_DIR) {
|
|
||||||
const osName = getOS();
|
|
||||||
const arch = getArch();
|
|
||||||
|
|
||||||
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}/${version}/0/${fileName}`;
|
|
||||||
|
|
||||||
const dest = path.join(cacheDir, fileName);
|
|
||||||
|
|
||||||
if (fs.existsSync(dest)) {
|
|
||||||
console.log('PWR file found in cache:', dest);
|
|
||||||
return dest;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Fetching PWR patch file:', url);
|
|
||||||
await downloadFile(url, dest, progressCallback);
|
|
||||||
console.log('PWR saved to:', dest);
|
|
||||||
|
|
||||||
return dest;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyPWR(pwrFile, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR) {
|
|
||||||
const butlerPath = await installButler(toolsDir);
|
|
||||||
const gameLatest = gameDir;
|
|
||||||
const stagingDir = path.join(gameLatest, 'staging-temp');
|
|
||||||
|
|
||||||
const clientPath = findClientPath(gameLatest);
|
|
||||||
|
|
||||||
if (clientPath) {
|
|
||||||
console.log('Game files detected, skipping patch installation.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(gameLatest)) {
|
|
||||||
fs.mkdirSync(gameLatest, { recursive: true });
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(stagingDir)) {
|
|
||||||
fs.mkdirSync(stagingDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Installing game patch...', null, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Installing game patch...');
|
|
||||||
|
|
||||||
if (!fs.existsSync(butlerPath)) {
|
|
||||||
throw new Error(`Butler tool not found at: ${butlerPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(pwrFile)) {
|
|
||||||
throw new Error(`PWR file not found at: ${pwrFile}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'apply',
|
|
||||||
'--staging-dir',
|
|
||||||
stagingDir,
|
|
||||||
pwrFile,
|
|
||||||
gameLatest
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const child = execFile(butlerPath, args, {
|
|
||||||
maxBuffer: 1024 * 1024 * 10,
|
|
||||||
timeout: 600000
|
|
||||||
}, (error, stdout, stderr) => {
|
|
||||||
if (error) {
|
|
||||||
console.error('Butler stderr:', stderr);
|
|
||||||
console.error('Butler stdout:', stdout);
|
|
||||||
reject(new Error(`Patch installation failed: ${error.message}${stderr ? '\n' + stderr : ''}`));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(stagingDir)) {
|
|
||||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Installation complete', null, null, null, null);
|
|
||||||
}
|
|
||||||
console.log('Installation complete');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateGameFiles(newVersion, progressCallback, gameDir = GAME_DIR, toolsDir = TOOLS_DIR, cacheDir = CACHE_DIR) {
|
|
||||||
let tempUpdateDir;
|
|
||||||
try {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Updating game files...', 0, null, null, null);
|
|
||||||
}
|
|
||||||
console.log(`Updating game files to version: ${newVersion}`);
|
|
||||||
|
|
||||||
tempUpdateDir = path.join(gameDir, '..', 'temp_update');
|
|
||||||
|
|
||||||
if (fs.existsSync(tempUpdateDir)) {
|
|
||||||
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
fs.mkdirSync(tempUpdateDir, { recursive: true });
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Downloading new game version...', 10, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pwrFile = await downloadPWR('release', newVersion, progressCallback, cacheDir);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Extracting new files...', 50, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
await applyPWR(pwrFile, progressCallback, tempUpdateDir, toolsDir);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Replacing game files...', 80, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
let userDataBackup = null;
|
|
||||||
const userDataPath = findUserDataRecursive(gameDir);
|
|
||||||
|
|
||||||
if (userDataPath && fs.existsSync(userDataPath)) {
|
|
||||||
userDataBackup = path.join(gameDir, '..', 'UserData_backup_' + Date.now());
|
|
||||||
console.log(`Backing up UserData from ${userDataPath} to: ${userDataBackup}`);
|
|
||||||
|
|
||||||
function copyRecursive(src, dest) {
|
|
||||||
const stat = fs.statSync(src);
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
if (!fs.existsSync(dest)) {
|
|
||||||
fs.mkdirSync(dest, { recursive: true });
|
|
||||||
}
|
|
||||||
const files = fs.readdirSync(src);
|
|
||||||
for (const file of files) {
|
|
||||||
copyRecursive(path.join(src, file), path.join(dest, file));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fs.copyFileSync(src, dest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
copyRecursive(userDataPath, userDataBackup);
|
|
||||||
} else {
|
|
||||||
console.log('No UserData folder found in game directory');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(gameDir)) {
|
|
||||||
console.log('Removing old game files...');
|
|
||||||
fs.rmSync(gameDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Game files updated successfully to version: ${newVersion}`);
|
|
||||||
|
|
||||||
if (userDataBackup && fs.existsSync(userDataBackup)) {
|
|
||||||
try {
|
|
||||||
fs.rmSync(userDataBackup, { recursive: true, force: true });
|
|
||||||
console.log('UserData backup cleaned up');
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Could not clean up UserData backup:', cleanupError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Waiting for file system sync...');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Game update completed', 100, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, updated: true, version: newVersion };
|
|
||||||
} catch (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)) {
|
|
||||||
fs.rmSync(tempUpdateDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Failed to update game files: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGameInstalled() {
|
|
||||||
const appDir = getResolvedAppDir();
|
|
||||||
const gameDir = path.join(appDir, 'release', 'package', 'game', 'latest');
|
|
||||||
const clientPath = findClientPath(gameDir);
|
|
||||||
return clientPath !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride) {
|
|
||||||
const customAppDir = getResolvedAppDir(installPathOverride);
|
|
||||||
const customCacheDir = path.join(customAppDir, 'cache');
|
|
||||||
const customToolsDir = path.join(customAppDir, 'butler');
|
|
||||||
const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest');
|
|
||||||
const customJreDir = path.join(customAppDir, 'release', 'package', 'jre', 'latest');
|
|
||||||
const userDataDir = path.join(customGameDir, 'Client', 'UserData');
|
|
||||||
|
|
||||||
[customAppDir, customCacheDir, customToolsDir].forEach(dir => {
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fs.existsSync(userDataDir)) {
|
|
||||||
fs.mkdirSync(userDataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
saveUsername(playerName);
|
|
||||||
if (installPathOverride) {
|
|
||||||
saveInstallPath(installPathOverride);
|
|
||||||
}
|
|
||||||
|
|
||||||
const gameLatest = customGameDir;
|
|
||||||
let clientPath = findClientPath(gameLatest);
|
|
||||||
|
|
||||||
if (clientPath) {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Game already installed', 100, null, null, null);
|
|
||||||
}
|
|
||||||
console.log('Game is already installed');
|
|
||||||
return { success: true, alreadyInstalled: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuredJava = (javaPathOverride !== undefined && javaPathOverride !== null
|
|
||||||
? javaPathOverride
|
|
||||||
: loadJavaPath() || '').trim();
|
|
||||||
let javaBin = null;
|
|
||||||
|
|
||||||
if (configuredJava) {
|
|
||||||
javaBin = await resolveJavaPath(configuredJava);
|
|
||||||
if (!javaBin) {
|
|
||||||
throw new Error(`Configured Java path not found: ${configuredJava}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await downloadJRE(progressCallback, customCacheDir, customJreDir);
|
|
||||||
} catch (error) {
|
|
||||||
const fallback = await detectSystemJava();
|
|
||||||
if (fallback) {
|
|
||||||
javaBin = fallback;
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!javaBin) {
|
|
||||||
javaBin = getJavaExec(customJreDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Fetching game files...', null, null, null, null);
|
|
||||||
}
|
|
||||||
console.log('Installing game files...');
|
|
||||||
|
|
||||||
const latestVersion = await getLatestClientVersion();
|
|
||||||
const pwrFile = await downloadPWR('release', latestVersion, progressCallback, customCacheDir);
|
|
||||||
await applyPWR(pwrFile, progressCallback, customGameDir, customToolsDir);
|
|
||||||
|
|
||||||
const homeUIResult = await downloadAndReplaceHomePageUI(customGameDir, progressCallback);
|
|
||||||
console.log('HomePage.ui update result after installation:', homeUIResult);
|
|
||||||
|
|
||||||
const logoResult = await downloadAndReplaceLogo(customGameDir, progressCallback);
|
|
||||||
console.log('Logo@2x.png update result after installation:', logoResult);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Installation complete', 100, null, null, null);
|
|
||||||
}
|
|
||||||
console.log('Game installation completed successfully');
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
installed: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uninstallGame() {
|
|
||||||
const appDir = getResolvedAppDir();
|
|
||||||
|
|
||||||
if (!fs.existsSync(appDir)) {
|
|
||||||
throw new Error('Game is not installed');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.rmSync(appDir, { recursive: true, force: true });
|
|
||||||
console.log('Game uninstalled successfully - removed entire HytaleF2P folder');
|
|
||||||
|
|
||||||
if (fs.existsSync(CONFIG_FILE)) {
|
|
||||||
const config = loadConfig();
|
|
||||||
delete config.installPath;
|
|
||||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to uninstall game: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkExistingGameInstallation() {
|
|
||||||
try {
|
|
||||||
const config = loadConfig();
|
|
||||||
|
|
||||||
if (!config.installPath || !config.installPath.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const installPath = config.installPath.trim();
|
|
||||||
const gameDir = path.join(installPath, 'HytaleF2P', 'release', 'package', 'game', 'latest');
|
|
||||||
|
|
||||||
if (!fs.existsSync(gameDir)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientPath = findClientPath(gameDir);
|
|
||||||
if (!clientPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userDataPath = findUserDataRecursive(gameDir);
|
|
||||||
|
|
||||||
return {
|
|
||||||
gameDir: gameDir,
|
|
||||||
clientPath: clientPath,
|
|
||||||
userDataPath: userDataPath,
|
|
||||||
installPath: installPath,
|
|
||||||
hasUserData: userDataPath && fs.existsSync(userDataPath)
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking existing game installation:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function repairGame(progressCallback) {
|
|
||||||
const appDir = getResolvedAppDir();
|
|
||||||
const gameDir = path.join(appDir, 'release', 'package', 'game', 'latest');
|
|
||||||
|
|
||||||
// Check if game exists
|
|
||||||
if (!fs.existsSync(gameDir)) {
|
|
||||||
throw new Error('Game directory not found. Cannot repair.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locate UserData
|
|
||||||
const userDataPath = findUserDataRecursive(gameDir);
|
|
||||||
let userDataBackup = null;
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Backing up user data...', 10, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup UserData
|
|
||||||
if (userDataPath && fs.existsSync(userDataPath)) {
|
|
||||||
userDataBackup = path.join(appDir, 'UserData_backup_repair_' + Date.now());
|
|
||||||
console.log(`Backing up UserData during repair from ${userDataPath} to ${userDataBackup}`);
|
|
||||||
|
|
||||||
// Copy function
|
|
||||||
function copyRecursive(src, dest) {
|
|
||||||
const stat = fs.statSync(src);
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
||||||
fs.readdirSync(src).forEach(child => copyRecursive(path.join(src, child), path.join(dest, child)));
|
|
||||||
} else {
|
|
||||||
fs.copyFileSync(src, dest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
copyRecursive(userDataPath, userDataBackup);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Restore UserData
|
|
||||||
if (userDataBackup && fs.existsSync(userDataBackup)) {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Restoring user data...', 90, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// installGame creates: path.join(customGameDir, 'Client', 'UserData')
|
|
||||||
const newGameDir = path.join(appDir, 'release', 'package', 'game', 'latest');
|
|
||||||
const newUserDataPath = path.join(newGameDir, 'Client', 'UserData');
|
|
||||||
|
|
||||||
if (!fs.existsSync(newUserDataPath)) {
|
|
||||||
fs.mkdirSync(newUserDataPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Restoring UserData to ${newUserDataPath}`);
|
|
||||||
|
|
||||||
function copyRecursive(src, dest) {
|
|
||||||
const stat = fs.statSync(src);
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
||||||
fs.readdirSync(src).forEach(child => copyRecursive(path.join(src, child), path.join(dest, child)));
|
|
||||||
} else {
|
|
||||||
fs.copyFileSync(src, dest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
copyRecursive(userDataBackup, newUserDataPath);
|
|
||||||
|
|
||||||
// Cleanup Backup
|
|
||||||
console.log('Cleaning up repair backup...');
|
|
||||||
fs.rmSync(userDataBackup, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Repair completed successfully!', 100, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, repaired: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
downloadPWR,
|
|
||||||
applyPWR,
|
|
||||||
updateGameFiles,
|
|
||||||
isGameInstalled,
|
|
||||||
installGame,
|
|
||||||
uninstallGame,
|
|
||||||
isGameInstalled,
|
|
||||||
installGame,
|
|
||||||
uninstallGame,
|
|
||||||
checkExistingGameInstallation,
|
|
||||||
repairGame
|
|
||||||
};
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { execFile } = require('child_process');
|
|
||||||
const { promisify } = require('util');
|
|
||||||
const axios = require('axios');
|
|
||||||
const AdmZip = require('adm-zip');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const tar = require('tar');
|
|
||||||
const { expandHome, JRE_DIR } = require('../core/paths');
|
|
||||||
const { getOS, getArch } = require('../utils/platformUtils');
|
|
||||||
const { loadConfig } = require('../core/config');
|
|
||||||
const { downloadFile } = require('../utils/fileManager');
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
const JAVA_EXECUTABLE = 'java' + (process.platform === 'win32' ? '.exe' : '');
|
|
||||||
|
|
||||||
async function findJavaOnPath(commandName = 'java') {
|
|
||||||
const lookupCmd = process.platform === 'win32' ? 'where' : 'which';
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileAsync(lookupCmd, [commandName]);
|
|
||||||
const line = stdout.split(/\r?\n/).map(lineItem => lineItem.trim()).find(Boolean);
|
|
||||||
return line || null;
|
|
||||||
} catch (err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMacJavaHome() {
|
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileAsync('/usr/libexec/java_home');
|
|
||||||
const home = stdout.trim();
|
|
||||||
if (!home) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return path.join(home, 'bin', JAVA_EXECUTABLE);
|
|
||||||
} catch (err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveJavaPath(inputPath) {
|
|
||||||
const trimmed = (inputPath || '').trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expanded = expandHome(trimmed);
|
|
||||||
if (fs.existsSync(expanded)) {
|
|
||||||
const stat = fs.statSync(expanded);
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
const candidate = path.join(expanded, 'bin', JAVA_EXECUTABLE);
|
|
||||||
return fs.existsSync(candidate) ? candidate : null;
|
|
||||||
}
|
|
||||||
return expanded;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!path.isAbsolute(expanded)) {
|
|
||||||
return await findJavaOnPath(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detectSystemJava() {
|
|
||||||
const envHome = process.env.JAVA_HOME;
|
|
||||||
if (envHome) {
|
|
||||||
const envJava = path.join(envHome, 'bin', JAVA_EXECUTABLE);
|
|
||||||
if (fs.existsSync(envJava)) {
|
|
||||||
return envJava;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const macJava = await getMacJavaHome();
|
|
||||||
if (macJava && fs.existsSync(macJava)) {
|
|
||||||
return macJava;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathJava = await findJavaOnPath('java');
|
|
||||||
if (pathJava && fs.existsSync(pathJava)) {
|
|
||||||
return pathJava;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadJavaPath() {
|
|
||||||
const config = loadConfig();
|
|
||||||
return config.javaPath || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBundledJavaPath(jreDir = JRE_DIR) {
|
|
||||||
const candidates = [
|
|
||||||
path.join(jreDir, 'bin', JAVA_EXECUTABLE)
|
|
||||||
];
|
|
||||||
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
candidates.push(path.join(jreDir, 'Contents', 'Home', 'bin', JAVA_EXECUTABLE));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (fs.existsSync(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getJavaExec(jreDir = JRE_DIR) {
|
|
||||||
const bundledJava = getBundledJavaPath(jreDir);
|
|
||||||
if (bundledJava) {
|
|
||||||
return bundledJava;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Notice: Java runtime not found, using system default');
|
|
||||||
return 'java';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getJavaDetection() {
|
|
||||||
const candidates = [];
|
|
||||||
const bundledJava = getBundledJavaPath() || path.join(JRE_DIR, 'bin', JAVA_EXECUTABLE);
|
|
||||||
|
|
||||||
candidates.push({
|
|
||||||
label: 'Bundled JRE',
|
|
||||||
path: bundledJava,
|
|
||||||
exists: fs.existsSync(bundledJava)
|
|
||||||
});
|
|
||||||
|
|
||||||
const javaHomeEnv = process.env.JAVA_HOME;
|
|
||||||
if (javaHomeEnv) {
|
|
||||||
const envJava = path.join(javaHomeEnv, 'bin', JAVA_EXECUTABLE);
|
|
||||||
candidates.push({
|
|
||||||
label: 'JAVA_HOME',
|
|
||||||
path: envJava,
|
|
||||||
exists: fs.existsSync(envJava),
|
|
||||||
note: fs.existsSync(envJava) ? '' : 'Not found'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
candidates.push({
|
|
||||||
label: 'JAVA_HOME',
|
|
||||||
path: '',
|
|
||||||
exists: false,
|
|
||||||
note: 'Not set'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
const macJava = await getMacJavaHome();
|
|
||||||
if (macJava) {
|
|
||||||
candidates.push({
|
|
||||||
label: 'java_home',
|
|
||||||
path: macJava,
|
|
||||||
exists: fs.existsSync(macJava),
|
|
||||||
note: fs.existsSync(macJava) ? '' : 'Not found'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
candidates.push({
|
|
||||||
label: 'java_home',
|
|
||||||
path: '',
|
|
||||||
exists: false,
|
|
||||||
note: 'Not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathJava = await findJavaOnPath('java');
|
|
||||||
if (pathJava) {
|
|
||||||
candidates.push({
|
|
||||||
label: 'PATH',
|
|
||||||
path: pathJava,
|
|
||||||
exists: true
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
candidates.push({
|
|
||||||
label: 'PATH',
|
|
||||||
path: '',
|
|
||||||
exists: false,
|
|
||||||
note: 'java not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
javaPath: loadJavaPath(),
|
|
||||||
candidates
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadJRE(progressCallback, cacheDir, jreDir = JRE_DIR) {
|
|
||||||
if (!fs.existsSync(cacheDir)) {
|
|
||||||
fs.mkdirSync(cacheDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const osName = getOS();
|
|
||||||
const arch = getArch();
|
|
||||||
|
|
||||||
const bundledJava = getBundledJavaPath(jreDir);
|
|
||||||
if (bundledJava) {
|
|
||||||
console.log('Java runtime found, skipping download');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Requesting Java runtime information...');
|
|
||||||
const response = await axios.get('https://launcher.hytale.com/version/release/jre.json', {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Accept-Language': 'en-US,en;q=0.9'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const jreData = response.data;
|
|
||||||
|
|
||||||
const osData = jreData.download_url[osName];
|
|
||||||
if (!osData) {
|
|
||||||
throw new Error(`Java runtime unavailable for platform: ${osName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const platform = osData[arch];
|
|
||||||
if (!platform) {
|
|
||||||
throw new Error(`Java runtime unavailable for architecture ${arch} on ${osName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = path.basename(platform.url);
|
|
||||||
const cacheFile = path.join(cacheDir, fileName);
|
|
||||||
|
|
||||||
if (!fs.existsSync(cacheFile)) {
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Fetching Java runtime...', null, null, null, null);
|
|
||||||
}
|
|
||||||
console.log('Fetching Java runtime...');
|
|
||||||
await downloadFile(platform.url, cacheFile, progressCallback);
|
|
||||||
console.log('Download finished');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Validating files...', null, null, null, null);
|
|
||||||
}
|
|
||||||
console.log('Validating files...');
|
|
||||||
const fileBuffer = fs.readFileSync(cacheFile);
|
|
||||||
const hashSum = crypto.createHash('sha256');
|
|
||||||
hashSum.update(fileBuffer);
|
|
||||||
const hex = hashSum.digest('hex');
|
|
||||||
|
|
||||||
if (hex !== platform.sha256) {
|
|
||||||
fs.unlinkSync(cacheFile);
|
|
||||||
throw new Error(`File validation failed: expected ${platform.sha256} but got ${hex}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Unpacking Java runtime...', null, null, null, null);
|
|
||||||
}
|
|
||||||
console.log('Unpacking Java runtime...');
|
|
||||||
await extractJRE(cacheFile, jreDir);
|
|
||||||
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
const javaCandidates = [
|
|
||||||
path.join(jreDir, 'bin', JAVA_EXECUTABLE),
|
|
||||||
path.join(jreDir, 'Contents', 'Home', 'bin', JAVA_EXECUTABLE)
|
|
||||||
];
|
|
||||||
for (const javaPath of javaCandidates) {
|
|
||||||
if (fs.existsSync(javaPath)) {
|
|
||||||
fs.chmodSync(javaPath, 0o755);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flattenJREDir(jreDir);
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(cacheFile);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Notice: could not delete cached Java files:', err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Java runtime ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function extractJRE(archivePath, destDir) {
|
|
||||||
if (fs.existsSync(destDir)) {
|
|
||||||
fs.rmSync(destDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
fs.mkdirSync(destDir, { recursive: true });
|
|
||||||
|
|
||||||
if (archivePath.endsWith('.zip')) {
|
|
||||||
return extractZip(archivePath, destDir);
|
|
||||||
} else if (archivePath.endsWith('.tar.gz')) {
|
|
||||||
return extractTarGz(archivePath, destDir);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Archive type not supported: ${archivePath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractZip(zipPath, dest) {
|
|
||||||
const zip = new AdmZip(zipPath);
|
|
||||||
const entries = zip.getEntries();
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const entryPath = path.join(dest, entry.entryName);
|
|
||||||
|
|
||||||
const resolvedPath = path.resolve(entryPath);
|
|
||||||
const resolvedDest = path.resolve(dest);
|
|
||||||
if (!resolvedPath.startsWith(resolvedDest)) {
|
|
||||||
throw new Error(`Invalid file path detected: ${entryPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory) {
|
|
||||||
fs.mkdirSync(entryPath, { recursive: true });
|
|
||||||
} else {
|
|
||||||
fs.mkdirSync(path.dirname(entryPath), { recursive: true });
|
|
||||||
fs.writeFileSync(entryPath, entry.getData());
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
fs.chmodSync(entryPath, entry.header.attr >>> 16);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTarGz(tarGzPath, dest) {
|
|
||||||
return tar.extract({
|
|
||||||
file: tarGzPath,
|
|
||||||
cwd: dest,
|
|
||||||
strip: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenJREDir(jreLatest) {
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(jreLatest, { withFileTypes: true });
|
|
||||||
|
|
||||||
if (entries.length !== 1 || !entries[0].isDirectory()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nested = path.join(jreLatest, entries[0].name);
|
|
||||||
const files = fs.readdirSync(nested, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const oldPath = path.join(nested, file.name);
|
|
||||||
const newPath = path.join(jreLatest, file.name);
|
|
||||||
fs.renameSync(oldPath, newPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.rmSync(nested, { recursive: true, force: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Notice: could not restructure Java directory:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
findJavaOnPath,
|
|
||||||
getMacJavaHome,
|
|
||||||
resolveJavaPath,
|
|
||||||
detectSystemJava,
|
|
||||||
loadJavaPath,
|
|
||||||
getBundledJavaPath,
|
|
||||||
getJavaExec,
|
|
||||||
getJavaDetection,
|
|
||||||
downloadJRE,
|
|
||||||
extractJRE,
|
|
||||||
JAVA_EXECUTABLE
|
|
||||||
};
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const axios = require('axios');
|
|
||||||
const { getModsPath, getProfilesDir } = require('../core/paths');
|
|
||||||
const { saveModsToConfig, loadModsFromConfig } = require('../core/config');
|
|
||||||
const profileManager = require('./profileManager');
|
|
||||||
|
|
||||||
const API_KEY = process.env.CURSEFORGE_API_KEY;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
|
||||||
return crypto.createHash('md5').update(filename).digest('hex').substring(0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractModName(filename) {
|
|
||||||
let name = path.parse(filename).name;
|
|
||||||
|
|
||||||
name = name.replace(/-v?\d+\.[\d\.]+.*$/i, '');
|
|
||||||
name = name.replace(/-\d+\.[\d\.]+.*$/i, '');
|
|
||||||
|
|
||||||
name = name.replace(/[-_]/g, ' ');
|
|
||||||
name = name.replace(/\b\w/g, l => l.toUpperCase());
|
|
||||||
|
|
||||||
return name || 'Unknown Mod';
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractVersion(filename) {
|
|
||||||
const versionMatch = filename.match(/v?(\d+\.[\d\.]+)/);
|
|
||||||
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) {
|
|
||||||
try {
|
|
||||||
// Sync first to ensure we detect any manually added mods and paths are correct
|
|
||||||
await syncModsForCurrentProfile();
|
|
||||||
|
|
||||||
const activeProfile = profileManager.getActiveProfile();
|
|
||||||
if (!activeProfile) return [];
|
|
||||||
|
|
||||||
const profileMods = activeProfile.mods || [];
|
|
||||||
|
|
||||||
// Use profile-specific paths
|
|
||||||
const profileModsPath = getProfileModsPath(activeProfile.id);
|
|
||||||
const profileDisabledModsPath = path.join(path.dirname(profileModsPath), 'DisabledMods');
|
|
||||||
|
|
||||||
if (!fs.existsSync(profileModsPath)) fs.mkdirSync(profileModsPath, { recursive: true });
|
|
||||||
if (!fs.existsSync(profileDisabledModsPath)) fs.mkdirSync(profileDisabledModsPath, { recursive: true });
|
|
||||||
|
|
||||||
const validMods = [];
|
|
||||||
|
|
||||||
for (const modConfig of profileMods) {
|
|
||||||
// Check if file exists in either location
|
|
||||||
const inEnabled = fs.existsSync(path.join(profileModsPath, modConfig.fileName));
|
|
||||||
const inDisabled = fs.existsSync(path.join(profileDisabledModsPath, modConfig.fileName));
|
|
||||||
|
|
||||||
if (inEnabled || inDisabled) {
|
|
||||||
validMods.push({
|
|
||||||
...modConfig,
|
|
||||||
// Set filePath based on physical location
|
|
||||||
filePath: inEnabled ? path.join(profileModsPath, modConfig.fileName) : path.join(profileDisabledModsPath, modConfig.fileName),
|
|
||||||
enabled: modConfig.enabled !== false // Default true
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.warn(`[ModManager] Mod ${modConfig.fileName} listed in profile but not found on disk.`);
|
|
||||||
// Include it so user can see it's missing or remove it
|
|
||||||
validMods.push({
|
|
||||||
...modConfig,
|
|
||||||
filePath: null,
|
|
||||||
missing: true,
|
|
||||||
enabled: modConfig.enabled !== false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return validMods;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading installed mods:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadMod(modInfo) {
|
|
||||||
try {
|
|
||||||
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) {
|
|
||||||
throw new Error('No download URL or file ID provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
let downloadUrl = modInfo.downloadUrl;
|
|
||||||
|
|
||||||
if (!downloadUrl && modInfo.fileId && modInfo.modId) {
|
|
||||||
const response = await axios.get(`https://api.curseforge.com/v1/mods/${modInfo.modId || modInfo.curseForgeId}/files/${modInfo.fileId || modInfo.curseForgeFileId}`, {
|
|
||||||
headers: {
|
|
||||||
'x-api-key': modInfo.apiKey || API_KEY,
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
downloadUrl = response.data.data.downloadUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!downloadUrl) {
|
|
||||||
throw new Error('Could not determine download URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = modInfo.fileName || `mod-${modInfo.modId}.jar`;
|
|
||||||
const filePath = path.join(modsPath, fileName);
|
|
||||||
|
|
||||||
const response = await axios({
|
|
||||||
method: 'get',
|
|
||||||
url: downloadUrl,
|
|
||||||
responseType: 'stream'
|
|
||||||
});
|
|
||||||
|
|
||||||
const writer = fs.createWriteStream(filePath);
|
|
||||||
response.data.pipe(writer);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
writer.on('finish', () => {
|
|
||||||
// Update Active Profile
|
|
||||||
const newMod = {
|
|
||||||
id: modInfo.id || generateModId(fileName),
|
|
||||||
name: modInfo.name || extractModName(fileName),
|
|
||||||
version: modInfo.version || '1.0.0',
|
|
||||||
description: modInfo.summary || modInfo.description || 'Downloaded from CurseForge',
|
|
||||||
author: modInfo.author || 'Unknown',
|
|
||||||
enabled: true,
|
|
||||||
fileName: fileName,
|
|
||||||
fileSize: fs.statSync(filePath).size,
|
|
||||||
dateInstalled: new Date().toISOString(),
|
|
||||||
curseForgeId: modInfo.modId,
|
|
||||||
curseForgeFileId: modInfo.fileId
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedMods = [...(activeProfile.mods || []), newMod];
|
|
||||||
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
success: true,
|
|
||||||
filePath: filePath,
|
|
||||||
fileName: fileName,
|
|
||||||
modInfo: newMod
|
|
||||||
});
|
|
||||||
});
|
|
||||||
writer.on('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error downloading mod:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uninstallMod(modId, modsPath) {
|
|
||||||
try {
|
|
||||||
const activeProfile = profileManager.getActiveProfile();
|
|
||||||
if (!activeProfile) throw new Error('No active profile');
|
|
||||||
|
|
||||||
const profileMods = activeProfile.mods || [];
|
|
||||||
const mod = profileMods.find(m => m.id === modId);
|
|
||||||
|
|
||||||
if (!mod) {
|
|
||||||
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 enabledPath = path.join(profileModsPath, mod.fileName);
|
|
||||||
const disabledPath = path.join(disabledModsPath, mod.fileName);
|
|
||||||
|
|
||||||
let fileRemoved = false;
|
|
||||||
// Try to remove file from both locations to be safe
|
|
||||||
if (fs.existsSync(enabledPath)) {
|
|
||||||
fs.unlinkSync(enabledPath);
|
|
||||||
fileRemoved = true;
|
|
||||||
}
|
|
||||||
if (fs.existsSync(disabledPath)) {
|
|
||||||
try { fs.unlinkSync(disabledPath); fileRemoved = true; } catch (e) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fileRemoved) {
|
|
||||||
console.warn('Mod file not found on filesystem, removing from profile anyway');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedMods = profileMods.filter(m => m.id !== modId);
|
|
||||||
profileManager.updateProfile(activeProfile.id, { mods: updatedMods });
|
|
||||||
|
|
||||||
console.log('Mod removed from profile');
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error uninstalling mod:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleMod(modId, modsPath) {
|
|
||||||
try {
|
|
||||||
const activeProfile = profileManager.getActiveProfile();
|
|
||||||
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 (!fs.existsSync(disabledModsPath)) fs.mkdirSync(disabledModsPath, { recursive: true });
|
|
||||||
|
|
||||||
const currentPath = mod.enabled ? path.join(profileModsPath, mod.fileName) : path.join(disabledModsPath, mod.fileName);
|
|
||||||
const targetDir = newEnabled ? profileModsPath : disabledModsPath;
|
|
||||||
const targetPath = path.join(targetDir, mod.fileName);
|
|
||||||
|
|
||||||
if (fs.existsSync(currentPath)) {
|
|
||||||
fs.renameSync(currentPath, targetPath);
|
|
||||||
} else {
|
|
||||||
// Fallback: check if it's already in target?
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, enabled: newEnabled };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling mod:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (fs.existsSync(globalModsPath)) {
|
|
||||||
const stats = fs.lstatSync(globalModsPath);
|
|
||||||
|
|
||||||
if (stats.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 (stats.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 {
|
|
||||||
fs.rmSync(globalModsPath, { recursive: true, force: true });
|
|
||||||
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 {
|
|
||||||
// 'junction' is key for Windows without admin
|
|
||||||
fs.symlinkSync(profileModsPath, globalModsPath, 'junction');
|
|
||||||
} 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 = {
|
|
||||||
loadInstalledMods,
|
|
||||||
downloadMod,
|
|
||||||
uninstallMod,
|
|
||||||
toggleMod,
|
|
||||||
syncModsForCurrentProfile,
|
|
||||||
generateModId,
|
|
||||||
extractModName,
|
|
||||||
extractVersion
|
|
||||||
};
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { downloadFile, findHomePageUIPath, findLogoPath } = require('../utils/fileManager');
|
|
||||||
|
|
||||||
async function downloadAndReplaceHomePageUI(gameDir, progressCallback) {
|
|
||||||
try {
|
|
||||||
console.log('Downloading HomePage.ui from server...');
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Downloading HomePage.ui...', null, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeUIUrl = 'https://files.hytalef2p.com/api/HomeUI';
|
|
||||||
const tempHomePath = path.join(path.dirname(gameDir), 'HomePage_temp.ui');
|
|
||||||
|
|
||||||
await downloadFile(homeUIUrl, tempHomePath);
|
|
||||||
|
|
||||||
const existingHomePath = findHomePageUIPath(gameDir);
|
|
||||||
|
|
||||||
if (existingHomePath && fs.existsSync(existingHomePath)) {
|
|
||||||
console.log('Found existing HomePage.ui at:', existingHomePath);
|
|
||||||
|
|
||||||
const backupPath = existingHomePath + '.backup';
|
|
||||||
if (!fs.existsSync(backupPath)) {
|
|
||||||
fs.copyFileSync(existingHomePath, backupPath);
|
|
||||||
console.log('Original HomePage.ui backed up');
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.copyFileSync(tempHomePath, existingHomePath);
|
|
||||||
console.log('HomePage.ui replaced successfully');
|
|
||||||
} else {
|
|
||||||
console.log('No existing HomePage.ui found, skipping replacement');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(tempHomePath)) {
|
|
||||||
fs.unlinkSync(tempHomePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('HomePage.ui updated', null, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, updated: true };
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error downloading/replacing HomePage.ui:', error);
|
|
||||||
|
|
||||||
const tempHomePath = path.join(path.dirname(gameDir), 'HomePage_temp.ui');
|
|
||||||
if (fs.existsSync(tempHomePath)) {
|
|
||||||
fs.unlinkSync(tempHomePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('HomePage.ui update failed, continuing...');
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadAndReplaceLogo(gameDir, progressCallback) {
|
|
||||||
try {
|
|
||||||
console.log('Downloading Logo@2x.png from server...');
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Downloading Logo@2x.png...', null, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const logoUrl = 'https://files.hytalef2p.com/api/Logo';
|
|
||||||
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
|
|
||||||
|
|
||||||
await downloadFile(logoUrl, tempLogoPath);
|
|
||||||
|
|
||||||
const existingLogoPath = findLogoPath(gameDir);
|
|
||||||
|
|
||||||
if (existingLogoPath && fs.existsSync(existingLogoPath)) {
|
|
||||||
console.log('Found existing Logo@2x.png at:', existingLogoPath);
|
|
||||||
|
|
||||||
const backupPath = existingLogoPath + '.backup';
|
|
||||||
if (!fs.existsSync(backupPath)) {
|
|
||||||
fs.copyFileSync(existingLogoPath, backupPath);
|
|
||||||
console.log('Original Logo@2x.png backed up');
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.copyFileSync(tempLogoPath, existingLogoPath);
|
|
||||||
console.log('Logo@2x.png replaced successfully');
|
|
||||||
} else {
|
|
||||||
console.log('No existing Logo@2x.png found, skipping replacement');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(tempLogoPath)) {
|
|
||||||
fs.unlinkSync(tempLogoPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Logo@2x.png updated', null, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, updated: true };
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error downloading/replacing Logo@2x.png:', error);
|
|
||||||
|
|
||||||
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
|
|
||||||
if (fs.existsSync(tempLogoPath)) {
|
|
||||||
fs.unlinkSync(tempLogoPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Logo@2x.png update failed, continuing...');
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
downloadAndReplaceHomePageUI,
|
|
||||||
findHomePageUIPath,
|
|
||||||
downloadAndReplaceLogo,
|
|
||||||
findLogoPath
|
|
||||||
};
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const { markAsLaunched, loadConfig } = require('../core/config');
|
|
||||||
const { checkExistingGameInstallation, updateGameFiles } = require('../managers/gameManager');
|
|
||||||
const { getInstalledClientVersion, getLatestClientVersion } = require('./versionManager');
|
|
||||||
|
|
||||||
async function proposeGameUpdate(existingGame, progressCallback) {
|
|
||||||
try {
|
|
||||||
console.log('Proposing game update for existing installation...');
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Checking for game updates...', 0, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [installedVersion, latestVersion] = await Promise.all([
|
|
||||||
getInstalledClientVersion(),
|
|
||||||
getLatestClientVersion()
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log(`Existing installation - Installed: ${installedVersion}, Latest: ${latestVersion}`);
|
|
||||||
|
|
||||||
const customAppDir = path.join(existingGame.installPath, 'HytaleF2P');
|
|
||||||
const customCacheDir = path.join(customAppDir, 'cache');
|
|
||||||
const customToolsDir = path.join(customAppDir, 'butler');
|
|
||||||
|
|
||||||
[customCacheDir, customToolsDir].forEach(dir => {
|
|
||||||
const fs = require('fs');
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Updating existing game installation...', 20, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateGameFiles(latestVersion, progressCallback, existingGame.gameDir, customToolsDir, customCacheDir);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Game update completed successfully', 100, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Existing game installation updated successfully');
|
|
||||||
return { success: true, updated: true };
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating existing game:', error);
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback(`Update failed: ${error.message}`, -1, null, null, null);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFirstLaunchCheck(progressCallback) {
|
|
||||||
try {
|
|
||||||
const config = loadConfig();
|
|
||||||
|
|
||||||
if (config.hasLaunchedBefore === true) {
|
|
||||||
return { isFirstLaunch: false, needsUpdate: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('First launch detected, checking for existing game installation...');
|
|
||||||
|
|
||||||
const existingGame = checkExistingGameInstallation();
|
|
||||||
|
|
||||||
if (!existingGame) {
|
|
||||||
console.log('No existing game installation found');
|
|
||||||
|
|
||||||
const hasUserData = config.installPath || config.username || config.javaPath ||
|
|
||||||
config.chatUsername || config.userUuids ||
|
|
||||||
Object.keys(config).length > 0;
|
|
||||||
|
|
||||||
if (hasUserData) {
|
|
||||||
console.log('Detected existing user data but no game, marking as launched');
|
|
||||||
markAsLaunched();
|
|
||||||
return { isFirstLaunch: false, needsUpdate: false };
|
|
||||||
} else {
|
|
||||||
markAsLaunched();
|
|
||||||
return { isFirstLaunch: true, needsUpdate: false, existingGame: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Existing game installation found:', {
|
|
||||||
gameDir: existingGame.gameDir,
|
|
||||||
hasUserData: existingGame.hasUserData
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
isFirstLaunch: true,
|
|
||||||
needsUpdate: true,
|
|
||||||
existingGame: existingGame
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in first launch check:', error);
|
|
||||||
markAsLaunched();
|
|
||||||
return { isFirstLaunch: true, needsUpdate: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
proposeGameUpdate,
|
|
||||||
handleFirstLaunchCheck
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
const axios = require('axios');
|
|
||||||
|
|
||||||
async function getHytaleNews() {
|
|
||||||
try {
|
|
||||||
const response = await axios.get('https://launcher.hytale.com/launcher-feed/release/feed.json', {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
||||||
},
|
|
||||||
timeout: 10000
|
|
||||||
});
|
|
||||||
|
|
||||||
const articles = response.data.articles || [];
|
|
||||||
return articles.map(article => ({
|
|
||||||
title: article.title || '',
|
|
||||||
description: article.description || '',
|
|
||||||
destUrl: article.dest_url || '',
|
|
||||||
imageUrl: article.image_url ?
|
|
||||||
(article.image_url.startsWith('http') ?
|
|
||||||
article.image_url :
|
|
||||||
`https://launcher.hytale.com/launcher-feed/release/${article.image_url}`
|
|
||||||
) : ''
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch news:', error.message);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getHytaleNews
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
const { PLAYER_ID_FILE, APP_DIR } = require('../core/paths');
|
|
||||||
|
|
||||||
function getOrCreatePlayerId() {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(APP_DIR)) {
|
|
||||||
fs.mkdirSync(APP_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(PLAYER_ID_FILE)) {
|
|
||||||
const data = JSON.parse(fs.readFileSync(PLAYER_ID_FILE, 'utf8'));
|
|
||||||
if (data.playerId) {
|
|
||||||
return data.playerId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPlayerId = uuidv4();
|
|
||||||
fs.writeFileSync(PLAYER_ID_FILE, JSON.stringify({
|
|
||||||
playerId: newPlayerId,
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
return newPlayerId;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error managing player ID:', error);
|
|
||||||
return uuidv4();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getOrCreatePlayerId
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
const axios = require('axios');
|
|
||||||
|
|
||||||
async function getLatestClientVersion() {
|
|
||||||
try {
|
|
||||||
console.log('Fetching latest client version from API...');
|
|
||||||
const response = await axios.get('https://files.hytalef2p.com/api/version_client', {
|
|
||||||
timeout: 5000,
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Hytale-F2P-Launcher'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data && response.data.client_version) {
|
|
||||||
const version = response.data.client_version;
|
|
||||||
console.log(`Latest client version: ${version}`);
|
|
||||||
return version;
|
|
||||||
} else {
|
|
||||||
console.log('Warning: Invalid API response, falling back to default version');
|
|
||||||
return '4.pwr';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching client version:', error.message);
|
|
||||||
console.log('Warning: API unavailable, falling back to default version');
|
|
||||||
return '4.pwr';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getInstalledClientVersion() {
|
|
||||||
try {
|
|
||||||
console.log('Fetching installed client version from API...');
|
|
||||||
const response = await axios.get('https://files.hytalef2p.com/api/clientCheck', {
|
|
||||||
timeout: 5000,
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Hytale-F2P-Launcher'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data && response.data.client_version) {
|
|
||||||
const version = response.data.client_version;
|
|
||||||
console.log(`Installed client version: ${version}`);
|
|
||||||
return version;
|
|
||||||
} else {
|
|
||||||
console.log('Warning: Invalid clientCheck API response');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching installed client version:', error.message);
|
|
||||||
console.log('Warning: clientCheck API unavailable');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getLatestClientVersion,
|
|
||||||
getInstalledClientVersion
|
|
||||||
};
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
const axios = require('axios');
|
|
||||||
|
|
||||||
const UPDATE_CHECK_URL = 'https://files.hytalef2p.com/api/version_launcher';
|
|
||||||
const CURRENT_VERSION = '2.0.2';
|
|
||||||
const GITHUB_DOWNLOAD_URL = 'https://github.com/amiayweb/Hytale-F2P/';
|
|
||||||
|
|
||||||
class UpdateManager {
|
|
||||||
constructor() {
|
|
||||||
this.updateAvailable = false;
|
|
||||||
this.remoteVersion = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkForUpdates() {
|
|
||||||
try {
|
|
||||||
console.log('Checking for updates...');
|
|
||||||
console.log(`Local version: ${CURRENT_VERSION}`);
|
|
||||||
|
|
||||||
const response = await axios.get(UPDATE_CHECK_URL, {
|
|
||||||
timeout: 5000,
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Hytale-F2P-Launcher'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data && response.data.launcher_version) {
|
|
||||||
this.remoteVersion = response.data.launcher_version;
|
|
||||||
console.log(`Remote version: ${this.remoteVersion}`);
|
|
||||||
|
|
||||||
if (this.remoteVersion !== CURRENT_VERSION) {
|
|
||||||
this.updateAvailable = true;
|
|
||||||
console.log('Update available!');
|
|
||||||
return {
|
|
||||||
updateAvailable: true,
|
|
||||||
currentVersion: CURRENT_VERSION,
|
|
||||||
newVersion: this.remoteVersion,
|
|
||||||
downloadUrl: GITHUB_DOWNLOAD_URL
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
console.log('Launcher is up to date');
|
|
||||||
return {
|
|
||||||
updateAvailable: false,
|
|
||||||
currentVersion: CURRENT_VERSION,
|
|
||||||
newVersion: this.remoteVersion
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('Invalid API response');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking for updates:', error.message);
|
|
||||||
return {
|
|
||||||
updateAvailable: false,
|
|
||||||
error: error.message,
|
|
||||||
currentVersion: CURRENT_VERSION
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getDownloadUrl() {
|
|
||||||
return GITHUB_DOWNLOAD_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
getUpdateInfo() {
|
|
||||||
return {
|
|
||||||
updateAvailable: this.updateAvailable,
|
|
||||||
currentVersion: CURRENT_VERSION,
|
|
||||||
remoteVersion: this.remoteVersion,
|
|
||||||
downloadUrl: this.getDownloadUrl()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = UpdateManager;
|
|
||||||
@@ -1,494 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const AdmZip = require('adm-zip');
|
|
||||||
|
|
||||||
// Domain configuration
|
|
||||||
const ORIGINAL_DOMAIN = 'hytale.com';
|
|
||||||
|
|
||||||
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 'sanasol.ws';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_NEW_DOMAIN = '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
|
|
||||||
*/
|
|
||||||
class ClientPatcher {
|
|
||||||
constructor() {
|
|
||||||
this.patchedFlag = '.patched_custom';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the target domain for patching
|
|
||||||
*/
|
|
||||||
getNewDomain() {
|
|
||||||
const domain = getTargetDomain();
|
|
||||||
if (domain.length !== ORIGINAL_DOMAIN.length) {
|
|
||||||
console.warn(`Warning: Domain "${domain}" length (${domain.length}) doesn't match original "${ORIGINAL_DOMAIN}" (${ORIGINAL_DOMAIN.length})`);
|
|
||||||
console.warn(`Using default domain: ${DEFAULT_NEW_DOMAIN}`);
|
|
||||||
return DEFAULT_NEW_DOMAIN;
|
|
||||||
}
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UTF-8 domain replacement for Java JAR files.
|
|
||||||
* Java stores strings in UTF-8 format in the constant pool.
|
|
||||||
*/
|
|
||||||
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++;
|
|
||||||
console.log(` Patched UTF-8 occurrence at offset 0x${pos.toString(16)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { buffer: result, count };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Smart domain replacement that handles both null-terminated and non-null-terminated strings.
|
|
||||||
* .NET AOT stores some strings in various formats:
|
|
||||||
* - Standard UTF-16LE (each char is 2 bytes with \x00 high byte)
|
|
||||||
* - Length-prefixed where last char may have metadata byte instead of \x00
|
|
||||||
*/
|
|
||||||
findAndReplaceDomainSmart(data, oldDomain, newDomain) {
|
|
||||||
let count = 0;
|
|
||||||
const result = Buffer.from(data);
|
|
||||||
|
|
||||||
const oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1));
|
|
||||||
const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1));
|
|
||||||
const oldLastChar = this.stringToUtf16LE(oldDomain.slice(-1));
|
|
||||||
const newLastChar = this.stringToUtf16LE(newDomain.slice(-1));
|
|
||||||
|
|
||||||
const oldLastCharByte = oldDomain.charCodeAt(oldDomain.length - 1);
|
|
||||||
const newLastCharByte = newDomain.charCodeAt(newDomain.length - 1);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
newUtf16NoLast.copy(result, pos);
|
|
||||||
|
|
||||||
result[lastCharPos] = newLastCharByte;
|
|
||||||
|
|
||||||
if (lastCharPos + 1 < result.length) {
|
|
||||||
const secondByte = result[lastCharPos + 1];
|
|
||||||
if (secondByte === 0x00) {
|
|
||||||
console.log(` Patched UTF-16LE occurrence at offset 0x${pos.toString(16)}`);
|
|
||||||
} else {
|
|
||||||
console.log(` Patched length-prefixed occurrence at offset 0x${pos.toString(16)} (metadata: 0x${secondByte.toString(16)})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { buffer: result, count };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patch Discord invite URLs from .gg/hytale to .gg/MHkEjepMQ7
|
|
||||||
*/
|
|
||||||
patchDiscordUrl(data) {
|
|
||||||
let count = 0;
|
|
||||||
const result = Buffer.from(data);
|
|
||||||
|
|
||||||
const oldUrl = '.gg/hytale';
|
|
||||||
const newUrl = '.gg/MHkEjepMQ7';
|
|
||||||
|
|
||||||
const oldUtf16 = this.stringToUtf16LE(oldUrl);
|
|
||||||
const newUtf16 = this.stringToUtf16LE(newUrl);
|
|
||||||
|
|
||||||
const positions = this.findAllOccurrences(result, oldUtf16);
|
|
||||||
|
|
||||||
for (const pos of positions) {
|
|
||||||
newUtf16.copy(result, pos);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { buffer: result, count };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark the client as patched
|
|
||||||
*/
|
|
||||||
markAsPatched(clientPath) {
|
|
||||||
const newDomain = this.getNewDomain();
|
|
||||||
const patchFlagFile = clientPath + this.patchedFlag;
|
|
||||||
const flagData = {
|
|
||||||
patchedAt: new Date().toISOString(),
|
|
||||||
originalDomain: ORIGINAL_DOMAIN,
|
|
||||||
targetDomain: newDomain,
|
|
||||||
patcherVersion: '1.0.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;
|
|
||||||
}
|
|
||||||
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
|
|
||||||
* @param {string} clientPath - Path to the HytaleClient binary
|
|
||||||
* @param {function} progressCallback - Optional callback for progress updates
|
|
||||||
* @returns {object} Result object with success status and details
|
|
||||||
*/
|
|
||||||
async patchClient(clientPath, progressCallback) {
|
|
||||||
const newDomain = this.getNewDomain();
|
|
||||||
console.log('=== Client Patcher ===');
|
|
||||||
console.log(`Target: ${clientPath}`);
|
|
||||||
console.log(`Replacing: ${ORIGINAL_DOMAIN} -> ${newDomain}`);
|
|
||||||
|
|
||||||
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('Patching domain references...');
|
|
||||||
const { buffer: patchedData, count } = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, newDomain);
|
|
||||||
|
|
||||||
console.log('Patching Discord URLs...');
|
|
||||||
const { buffer: finalData, count: discordCount } = this.patchDiscordUrl(patchedData);
|
|
||||||
|
|
||||||
if (count === 0 && discordCount === 0) {
|
|
||||||
console.log('No occurrences found - binary may already be modified or has different format');
|
|
||||||
return { success: true, patchCount: 0, warning: 'No occurrences found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Writing patched binary...', 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Writing patched binary...');
|
|
||||||
fs.writeFileSync(clientPath, finalData);
|
|
||||||
|
|
||||||
this.markAsPatched(clientPath);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Patching complete', 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Successfully patched ${count} domain occurrences and ${discordCount} Discord URLs`);
|
|
||||||
console.log('=== Patching Complete ===');
|
|
||||||
|
|
||||||
return { success: true, patchCount: count + discordCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patch the server JAR to use the custom domain
|
|
||||||
* JAR files are ZIP archives, so we need to extract, patch class files, and repackage
|
|
||||||
* @param {string} serverPath - Path to the HytaleServer.jar
|
|
||||||
* @param {function} progressCallback - Optional callback for progress updates
|
|
||||||
* @returns {object} Result object with success status and details
|
|
||||||
*/
|
|
||||||
async patchServer(serverPath, progressCallback) {
|
|
||||||
const newDomain = this.getNewDomain();
|
|
||||||
console.log('=== Server Patcher ===');
|
|
||||||
console.log(`Target: ${serverPath}`);
|
|
||||||
console.log(`Replacing: ${ORIGINAL_DOMAIN} -> ${newDomain}`);
|
|
||||||
|
|
||||||
if (!fs.existsSync(serverPath)) {
|
|
||||||
const error = `Server JAR not found: ${serverPath}`;
|
|
||||||
console.error(error);
|
|
||||||
return { success: false, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isPatchedAlready(serverPath)) {
|
|
||||||
console.log(`Server already patched for ${newDomain}, skipping`);
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Server already patched', 100);
|
|
||||||
}
|
|
||||||
return { success: true, alreadyPatched: true, patchCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Preparing to patch server...', 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Creating backup...');
|
|
||||||
this.backupClient(serverPath);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Extracting server JAR...', 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Opening server JAR...');
|
|
||||||
const zip = new AdmZip(serverPath);
|
|
||||||
const entries = zip.getEntries();
|
|
||||||
console.log(`JAR contains ${entries.length} entries`);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Patching class files...', 40);
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalCount = 0;
|
|
||||||
const oldUtf8 = this.stringToUtf8(ORIGINAL_DOMAIN);
|
|
||||||
const newUtf8 = this.stringToUtf8(newDomain);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const name = entry.entryName;
|
|
||||||
if (name.endsWith('.class') || name.endsWith('.properties') ||
|
|
||||||
name.endsWith('.json') || name.endsWith('.xml') || name.endsWith('.yml')) {
|
|
||||||
|
|
||||||
const data = entry.getData();
|
|
||||||
|
|
||||||
if (data.includes(oldUtf8)) {
|
|
||||||
const { buffer: patchedData, count } = this.findAndReplaceDomainUtf8(data, ORIGINAL_DOMAIN, newDomain);
|
|
||||||
if (count > 0) {
|
|
||||||
zip.updateFile(entry.entryName, patchedData);
|
|
||||||
console.log(` Patched ${count} occurrences in ${name}`);
|
|
||||||
totalCount += count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalCount === 0) {
|
|
||||||
console.log('No occurrences of hytale.com found in server JAR entries');
|
|
||||||
return { success: true, patchCount: 0, warning: 'No domain occurrences found in JAR' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Writing patched JAR...', 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Writing patched JAR...');
|
|
||||||
zip.writeZip(serverPath);
|
|
||||||
|
|
||||||
this.markAsPatched(serverPath);
|
|
||||||
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback('Server patching complete', 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Successfully patched ${totalCount} occurrences in server`);
|
|
||||||
console.log('=== Server Patching Complete ===');
|
|
||||||
|
|
||||||
return { success: true, patchCount: totalCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
* @param {string} gameDir - Path to the game directory
|
|
||||||
* @param {function} progressCallback - Optional callback for progress updates
|
|
||||||
*/
|
|
||||||
async ensureClientPatched(gameDir, progressCallback) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} 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();
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const axios = require('axios');
|
|
||||||
|
|
||||||
async function downloadFile(url, dest, progressCallback, maxRetries = 3) {
|
|
||||||
let lastError = null;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
console.log(`Download attempt ${attempt + 1}/${maxRetries} for ${url}`);
|
|
||||||
|
|
||||||
if (attempt > 0 && progressCallback) {
|
|
||||||
progressCallback(`Retry ${attempt}/${maxRetries - 1}...`, null, null, null, null);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000 * attempt)); // Délai progressif
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios({
|
|
||||||
method: 'GET',
|
|
||||||
url: url,
|
|
||||||
responseType: 'stream',
|
|
||||||
timeout: 60000, // 60 secondes timeout
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
||||||
'Accept': '*/*',
|
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
|
||||||
'Referer': 'https://launcher.hytale.com/',
|
|
||||||
'Connection': 'keep-alive'
|
|
||||||
},
|
|
||||||
// Configuration Axios pour la robustesse réseau
|
|
||||||
validateStatus: function (status) {
|
|
||||||
return status >= 200 && status < 300;
|
|
||||||
},
|
|
||||||
// Retry configuration
|
|
||||||
maxRedirects: 5,
|
|
||||||
// Network resilience
|
|
||||||
family: 4 // Force IPv4
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalSize = parseInt(response.headers['content-length'], 10);
|
|
||||||
let downloaded = 0;
|
|
||||||
let lastProgressTime = Date.now();
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Nettoyer le fichier de destination s'il existe
|
|
||||||
if (fs.existsSync(dest)) {
|
|
||||||
fs.unlinkSync(dest);
|
|
||||||
}
|
|
||||||
|
|
||||||
const writer = fs.createWriteStream(dest);
|
|
||||||
let downloadStalled = false;
|
|
||||||
let stalledTimeout = null;
|
|
||||||
|
|
||||||
response.data.on('data', (chunk) => {
|
|
||||||
downloaded += chunk.length;
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// Reset stalled timer on data received
|
|
||||||
if (stalledTimeout) {
|
|
||||||
clearTimeout(stalledTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set new stalled timer (30 seconds without data = stalled)
|
|
||||||
stalledTimeout = setTimeout(() => {
|
|
||||||
downloadStalled = true;
|
|
||||||
writer.destroy();
|
|
||||||
response.data.destroy();
|
|
||||||
}, 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);
|
|
||||||
lastProgressTime = now;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
response.data.on('error', (error) => {
|
|
||||||
if (stalledTimeout) {
|
|
||||||
clearTimeout(stalledTimeout);
|
|
||||||
}
|
|
||||||
console.error(`Stream error on attempt ${attempt + 1}:`, error.code || error.message);
|
|
||||||
writer.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
response.data.pipe(writer);
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
writer.on('finish', () => {
|
|
||||||
if (stalledTimeout) {
|
|
||||||
clearTimeout(stalledTimeout);
|
|
||||||
}
|
|
||||||
if (!downloadStalled) {
|
|
||||||
console.log(`Download completed successfully on attempt ${attempt + 1}`);
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error('Download stalled'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
writer.on('error', (error) => {
|
|
||||||
if (stalledTimeout) {
|
|
||||||
clearTimeout(stalledTimeout);
|
|
||||||
}
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
response.data.on('error', (error) => {
|
|
||||||
if (stalledTimeout) {
|
|
||||||
clearTimeout(stalledTimeout);
|
|
||||||
}
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Si on arrive ici, le téléchargement a réussi
|
|
||||||
return;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
console.error(`Download attempt ${attempt + 1} failed:`, error.code || error.message);
|
|
||||||
|
|
||||||
// Nettoyer le fichier partiel en cas d'erreur
|
|
||||||
if (fs.existsSync(dest)) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(dest);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Could not cleanup partial file:', cleanupError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier si c'est une erreur réseau que l'on peut retry
|
|
||||||
const retryableErrors = ['ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT', 'ESOCKETTIMEDOUT', 'EPROTO'];
|
|
||||||
const isRetryable = retryableErrors.includes(error.code) ||
|
|
||||||
error.message.includes('timeout') ||
|
|
||||||
error.message.includes('stalled') ||
|
|
||||||
(error.response && error.response.status >= 500);
|
|
||||||
|
|
||||||
if (!isRetryable || attempt === maxRetries - 1) {
|
|
||||||
console.error(`Non-retryable error or max retries reached: ${error.code || error.message}`);
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Retryable error detected, will retry in ${2000 * (attempt + 1)}ms...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Download failed after ${maxRetries} attempts. Last error: ${lastError?.code || lastError?.message || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findHomePageUIPath(gameLatest) {
|
|
||||||
function searchDirectory(dir) {
|
|
||||||
try {
|
|
||||||
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.isFile() && item.name === 'HomePage.ui') {
|
|
||||||
return path.join(dir, item.name);
|
|
||||||
} else if (item.isDirectory()) {
|
|
||||||
const found = searchDirectory(path.join(dir, item.name));
|
|
||||||
if (found) {
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(gameLatest)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchDirectory(gameLatest);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLogoPath(gameLatest) {
|
|
||||||
function searchDirectory(dir) {
|
|
||||||
try {
|
|
||||||
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.isFile() && item.name === 'Logo@2x.png') {
|
|
||||||
return path.join(dir, item.name);
|
|
||||||
} else if (item.isDirectory()) {
|
|
||||||
const found = searchDirectory(path.join(dir, item.name));
|
|
||||||
if (found) {
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(gameLatest)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchDirectory(gameLatest);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
downloadFile,
|
|
||||||
findHomePageUIPath,
|
|
||||||
findLogoPath
|
|
||||||
};
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
const { execSync } = require('child_process');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
function getOS() {
|
|
||||||
if (process.platform === 'win32') return 'windows';
|
|
||||||
if (process.platform === 'darwin') return 'darwin';
|
|
||||||
if (process.platform === 'linux') return 'linux';
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getArch() {
|
|
||||||
return process.arch === 'x64' ? 'amd64' : process.arch;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWaylandSession() {
|
|
||||||
if (process.platform !== 'linux') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionType = process.env.XDG_SESSION_TYPE;
|
|
||||||
if (sessionType && sessionType.toLowerCase() === 'wayland') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.WAYLAND_DISPLAY) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sessionId = process.env.XDG_SESSION_ID;
|
|
||||||
if (sessionId) {
|
|
||||||
const output = execSync(`loginctl show-session ${sessionId} -p Type`, { encoding: 'utf8' });
|
|
||||||
if (output && output.toLowerCase().includes('wayland')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupWaylandEnvironment() {
|
|
||||||
if (process.platform !== 'linux') {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isWaylandSession()) {
|
|
||||||
console.log('Detected X11 session, using default environment');
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Detected Wayland session, configuring environment...');
|
|
||||||
|
|
||||||
const envVars = {
|
|
||||||
SDL_VIDEODRIVER: 'wayland',
|
|
||||||
GDK_BACKEND: 'wayland',
|
|
||||||
QT_QPA_PLATFORM: 'wayland',
|
|
||||||
MOZ_ENABLE_WAYLAND: '1',
|
|
||||||
_JAVA_AWT_WM_NONREPARENTING: '1'
|
|
||||||
};
|
|
||||||
|
|
||||||
envVars.ELECTRON_OZONE_PLATFORM_HINT = 'wayland';
|
|
||||||
|
|
||||||
console.log('Wayland environment variables:', 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 = {
|
|
||||||
getOS,
|
|
||||||
getArch,
|
|
||||||
isWaylandSession,
|
|
||||||
setupWaylandEnvironment,
|
|
||||||
detectGpu,
|
|
||||||
setupGpuEnvironment
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
provider: github
|
|
||||||
owner: amiayweb # Change to your own GitHub username
|
|
||||||
repo: Hytale-F2P
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,39 +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
|
|
||||||
```
|
|
||||||
|
|
||||||
Built executables will be in the `dist/` directory
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
# 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
|
|
||||||
5056
package-lock.json
generated
5056
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
150
package.json
150
package.json
@@ -1,150 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "hytale-f2p-launcher",
|
|
||||||
"version": "2.0.11",
|
|
||||||
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
|
||||||
"homepage": "https://github.com/amiayweb/Hytale-F2P",
|
|
||||||
"main": "main.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "electron .",
|
|
||||||
"dev": "electron . --dev",
|
|
||||||
"build": "electron-builder",
|
|
||||||
"build:win": "electron-builder --win",
|
|
||||||
"build:linux": "electron-builder --linux",
|
|
||||||
"build:mac": "electron-builder --mac",
|
|
||||||
"build:all": "electron-builder --win --linux --mac"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"hytale",
|
|
||||||
"launcher",
|
|
||||||
"game",
|
|
||||||
"client",
|
|
||||||
"cross-platform",
|
|
||||||
"electron",
|
|
||||||
"auto-update",
|
|
||||||
"mod-manager",
|
|
||||||
"chat"
|
|
||||||
],
|
|
||||||
"maintainers": [
|
|
||||||
{
|
|
||||||
"name": "Terromur",
|
|
||||||
"url": "https://github.com/Terromur"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fari Gading",
|
|
||||||
"email": "fazrigading@gmail.com",
|
|
||||||
"url": "https://github.com/fazrigading"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"author": {
|
|
||||||
"name": "AMIAY",
|
|
||||||
"email": "support@amiay.dev"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"devDependencies": {
|
|
||||||
"electron": "^40.0.0",
|
|
||||||
"electron-builder": "^26.4.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"adm-zip": "^0.5.10",
|
|
||||||
"axios": "^1.6.0",
|
|
||||||
"discord-rpc": "^4.0.1",
|
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"electron-updater": "^6.7.3",
|
|
||||||
"tar": "^6.2.1",
|
|
||||||
"uuid": "^9.0.1"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"tar": "$tar"
|
|
||||||
},
|
|
||||||
"build": {
|
|
||||||
"appId": "com.hytalef2p.launcher",
|
|
||||||
"productName": "Hytale F2P Launcher",
|
|
||||||
"artifactName": "${name}_${version}_${arch}.${ext}",
|
|
||||||
"directories": {
|
|
||||||
"output": "dist"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"main.js",
|
|
||||||
"preload.js",
|
|
||||||
"backend/**/*",
|
|
||||||
"GUI/**/*",
|
|
||||||
"package.json",
|
|
||||||
".env"
|
|
||||||
],
|
|
||||||
"win": {
|
|
||||||
"target": [
|
|
||||||
{
|
|
||||||
"target": "nsis",
|
|
||||||
"arch": [
|
|
||||||
"x64",
|
|
||||||
"arm64"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"icon": "icon.ico"
|
|
||||||
},
|
|
||||||
"linux": {
|
|
||||||
"target": [
|
|
||||||
{
|
|
||||||
"target": "AppImage",
|
|
||||||
"arch": [
|
|
||||||
"x64",
|
|
||||||
"arm64"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"target": "deb",
|
|
||||||
"arch": [
|
|
||||||
"x64",
|
|
||||||
"arm64"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"target": "rpm",
|
|
||||||
"arch": [
|
|
||||||
"x64",
|
|
||||||
"arm64"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"target": "pacman",
|
|
||||||
"arch": [
|
|
||||||
"x64",
|
|
||||||
"arm64"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"icon": "build/icon.png",
|
|
||||||
"category": "Game"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
107
preload.js
107
preload.js
@@ -1,107 +0,0 @@
|
|||||||
const { contextBridge, ipcRenderer } = require('electron');
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
|
||||||
launchGame: (playerName, javaPath, installPath, gpuPreference) => ipcRenderer.invoke('launch-game', playerName, javaPath, installPath, gpuPreference),
|
|
||||||
installGame: (playerName, javaPath, installPath) => ipcRenderer.invoke('install-game', playerName, javaPath, installPath),
|
|
||||||
closeWindow: () => ipcRenderer.invoke('window-close'),
|
|
||||||
minimizeWindow: () => ipcRenderer.invoke('window-minimize'),
|
|
||||||
maximizeWindow: () => ipcRenderer.invoke('window-maximize'),
|
|
||||||
getVersion: () => ipcRenderer.invoke('get-version'),
|
|
||||||
saveUsername: (username) => ipcRenderer.invoke('save-username', username),
|
|
||||||
loadUsername: () => ipcRenderer.invoke('load-username'),
|
|
||||||
saveChatUsername: (chatUsername) => ipcRenderer.invoke('save-chat-username', chatUsername),
|
|
||||||
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),
|
|
||||||
loadJavaPath: () => ipcRenderer.invoke('load-java-path'),
|
|
||||||
saveInstallPath: (installPath) => ipcRenderer.invoke('save-install-path', installPath),
|
|
||||||
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'),
|
|
||||||
selectInstallPath: () => ipcRenderer.invoke('select-install-path'),
|
|
||||||
browseJavaPath: () => ipcRenderer.invoke('browse-java-path'),
|
|
||||||
isGameInstalled: () => ipcRenderer.invoke('is-game-installed'),
|
|
||||||
uninstallGame: () => ipcRenderer.invoke('uninstall-game'),
|
|
||||||
repairGame: () => ipcRenderer.invoke('repair-game'),
|
|
||||||
getHytaleNews: () => ipcRenderer.invoke('get-hytale-news'),
|
|
||||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
|
||||||
openExternalLink: (url) => ipcRenderer.invoke('openExternalLink', url),
|
|
||||||
openGameLocation: () => ipcRenderer.invoke('open-game-location'),
|
|
||||||
saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings),
|
|
||||||
loadSettings: () => ipcRenderer.invoke('load-settings'),
|
|
||||||
getEnvVar: (key) => ipcRenderer.invoke('get-env-var', key),
|
|
||||||
getLocalAppData: () => ipcRenderer.invoke('get-local-app-data'),
|
|
||||||
getModsPath: () => ipcRenderer.invoke('get-mods-path'),
|
|
||||||
loadInstalledMods: (modsPath) => ipcRenderer.invoke('load-installed-mods', modsPath),
|
|
||||||
downloadMod: (modInfo) => ipcRenderer.invoke('download-mod', modInfo),
|
|
||||||
uninstallMod: (modId, modsPath) => ipcRenderer.invoke('uninstall-mod', modId, modsPath),
|
|
||||||
toggleMod: (modId, modsPath) => ipcRenderer.invoke('toggle-mod', modId, modsPath),
|
|
||||||
selectModFiles: () => ipcRenderer.invoke('select-mod-files'),
|
|
||||||
copyModFile: (sourcePath, modsPath) => ipcRenderer.invoke('copy-mod-file', sourcePath, modsPath),
|
|
||||||
onProgressUpdate: (callback) => {
|
|
||||||
ipcRenderer.on('progress-update', (event, data) => callback(data));
|
|
||||||
},
|
|
||||||
onProgressComplete: (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'),
|
|
||||||
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
|
||||||
openDownloadPage: () => ipcRenderer.invoke('open-download-page'),
|
|
||||||
getUpdateInfo: () => ipcRenderer.invoke('get-update-info'),
|
|
||||||
onUpdatePopup: (callback) => {
|
|
||||||
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'),
|
|
||||||
|
|
||||||
acceptFirstLaunchUpdate: (existingGame) => ipcRenderer.invoke('accept-first-launch-update', existingGame),
|
|
||||||
markAsLaunched: () => ipcRenderer.invoke('mark-as-launched'),
|
|
||||||
onFirstLaunchUpdate: (callback) => {
|
|
||||||
ipcRenderer.on('show-first-launch-update', (event, data) => callback(data));
|
|
||||||
},
|
|
||||||
onFirstLaunchWelcome: (callback) => {
|
|
||||||
ipcRenderer.on('show-first-launch-welcome', () => callback());
|
|
||||||
},
|
|
||||||
onFirstLaunchProgress: (callback) => {
|
|
||||||
ipcRenderer.on('first-launch-progress', (event, data) => callback(data));
|
|
||||||
},
|
|
||||||
onLockPlayButton: (callback) => {
|
|
||||||
ipcRenderer.on('lock-play-button', (event, locked) => callback(locked));
|
|
||||||
},
|
|
||||||
|
|
||||||
getLogDirectory: () => ipcRenderer.invoke('get-log-directory'),
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user