diff options
| author | 2026-01-18 12:24:29 +0800 | |
|---|---|---|
| committer | 2026-01-18 12:24:29 +0800 | |
| commit | fd00ac6878b2cee9337b9e92d0c990ecdce9a346 (patch) | |
| tree | bb5540f763dc0061877c9d9ac53747d79193eecc | |
| parent | ad36e0ce82770f9b3509ddb1cf96bc3422969806 (diff) | |
| parent | 6d82ab2275130f3bafdb7ec664297eb700321526 (diff) | |
| download | DropOut-fd00ac6878b2cee9337b9e92d0c990ecdce9a346.tar.gz DropOut-fd00ac6878b2cee9337b9e92d0c990ecdce9a346.zip | |
Merge pull request #58 from HsiangNianian/main
66 files changed, 7286 insertions, 338 deletions
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..063bbb7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,211 @@ +name: Bug Report +description: Report a bug or issue with DropOut Minecraft Launcher +title: "[Bug]: " +labels: ["bug", "needs-triage"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to report a bug! Please fill out the form below to help us understand and fix the issue. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + description: Please confirm you have completed the following before submitting. Issues that check "I have not read carefully" or fail to meet required items may be closed immediately. + options: + - label: I understand that Issues are for reporting and solving problems, not for comments or complaints. I will provide as much information as possible to help resolve the issue. + required: false + - label: I have not read carefully and just clicked through everything, believing this won't affect issue handling. + required: false + - label: I have filled in a short and clear title so that developers can quickly identify the issue when browsing the Issue list, rather than "a suggestion" or "stuck" etc. + required: false + - label: I have searched existing issues to ensure this is not a duplicate + required: false + - label: I am using the latest version of DropOut + required: false + - label: I have read the README and documentation + required: false + + - type: input + id: version + attributes: + label: DropOut Version + description: What version of DropOut are you using? + placeholder: "e.g., 0.1.23" + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + description: Which operating system are you using? + options: + - Windows 11 + - Windows 10 + - Windows 8,8.1 + - Windows 7 + - macOS (Apple Silicon) + - macOS (Intel) + - Linux (Debian/Ubuntu) + - Linux (Arch) + - Linux (Fedora/RHEL) + - Other Linux + validations: + required: true + + - type: input + id: os-version + attributes: + label: OS Version + description: Specific version of your operating system + placeholder: "e.g., Windows 11 23H2, macOS 14.2, Ubuntu 22.04" + validations: + required: true + + - type: dropdown + id: minecraft-version + attributes: + label: Minecraft Version + description: Which Minecraft version are you trying to launch? + options: + - "1.21.x" + - "1.20.x" + - "1.19.x" + - "1.18.x" + - "1.17.x" + - "1.16.x" + - "1.12.x" + - Other (specify in description) + validations: + required: true + + - type: dropdown + id: mod-loader + attributes: + label: Mod Loader + description: Are you using a mod loader? + options: + - None (Vanilla) + - Fabric + - Forge + - Not applicable + validations: + required: true + + - type: input + id: java-version + attributes: + label: Java Version + description: Which Java version are you using? + placeholder: "e.g., Java 21.0.1, Java 17.0.9, Java 8u381" + validations: + required: true + + - type: dropdown + id: java-source + attributes: + label: Java Source + description: Where did you get Java from? + options: + - Auto-detected by DropOut + - Downloaded via DropOut + - Manually installed (Oracle) + - Manually installed (Sdkman) + - Manually installed (Homebrew) + - Manually installed (Chocolatey) + - Manually installed (Other) + - Unknown + validations: + required: false + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is + placeholder: Describe the issue you're experiencing... + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps 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: What did you expect to happen? + placeholder: Describe what you expected to happen... + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? + placeholder: Describe what actually happened... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs and Error Messages + description: | + Please upload log files from the DropOut launcher terminal, then paste the log link here. + + **How to upload logs:** + 1. Open the terminal in DropOut launcher + 2. Use the built-in log upload feature + 3. Copy the generated log link and paste it below + + If automatic upload is unavailable, you can manually retrieve logs from: + - **macOS**: `$HOME/Library/Application Support/com.dropout.launcher/logs` + - **Linux**: `$HOME/.local/share/com.dropout.launcher` + placeholder: | + Paste log link or log content... + validations: + required: false + + - type: textarea + id: screenshots + attributes: + label: Screenshots or Screen Recordings + description: | + If applicable, add screenshots or screen recordings to help explain your problem. + + **Screenshot Requirements:** + - Use native screenshot tools (Windows: `Win+Shift+S`, macOS: `Cmd+Shift+4`, Linux: `gnome-screenshot`/`spectacle`) + - DO NOT use QQ/WeChat/Discord screenshot tools as they may alter resolution and aspect ratio + - Provide unobstructed original images, avoid window borders or overlays + - For game-related issues, capture the actual game window + - Keep the original resolution and DPI + + You can compress large files before uploading. + placeholder: Drag and drop images here or paste image URLs + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context about the problem here + placeholder: Any additional information that might be helpful... + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/cn-bug-report.yml b/.github/ISSUE_TEMPLATE/cn-bug-report.yml new file mode 100644 index 0000000..a128e28 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/cn-bug-report.yml @@ -0,0 +1,211 @@ +name: Bug 反馈(中文) +description: 报告 DropOut Minecraft 启动器的错误或问题 +title: "[Bug]: " +labels: ["bug", "needs-triage"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + 感谢您花时间报告 Bug!请填写以下表单以帮助我们理解和修复问题。 + + - type: checkboxes + id: prerequisites + attributes: + label: 前置确认 + description: 请确认自己完成了下列项目之后再进行勾选,若未完成必选项或勾选了"我未仔细阅读"选项将视为自愿接受被直接关闭 Issue + options: + - label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息帮助问题解决 + required: false + - label: 我未仔细阅读这些内容,只是一键已读所有内容,并相信这不会影响问题的处理 + required: false + - label: 我填写了简短且清晰明确的标题,以便开发者在翻阅 Issue 列表时能快速确定大致问题,而不是"一个建议"、"卡住了"等 + required: false + - label: 我已搜索现有 Issue,确认这不是重复问题 + required: false + - label: 我正在使用最新版本的 DropOut + required: false + - label: 我已阅读 README 和文档 + required: false + + - type: input + id: version + attributes: + label: DropOut 版本 + description: 您正在使用的 DropOut 版本是? + placeholder: "例如:0.1.23" + validations: + required: true + + - type: dropdown + id: os + attributes: + label: 操作系统 + description: 您使用的操作系统是? + options: + - Windows 11 + - Windows 10 + - Windows 8/8.1 + - Windows 7 + - macOS (Apple Silicon) + - macOS (Intel) + - Linux (Debian/Ubuntu) + - Linux (Arch) + - Linux (Fedora/RHEL) + - 其他 Linux + validations: + required: true + + - type: input + id: os-version + attributes: + label: 操作系统版本 + description: 操作系统的具体版本号 + placeholder: "例如:Windows 11 23H2, macOS 14.2, Ubuntu 22.04" + validations: + required: true + + - type: dropdown + id: minecraft-version + attributes: + label: Minecraft 版本 + description: 您尝试启动的 Minecraft 版本是? + options: + - "1.21.x" + - "1.20.x" + - "1.19.x" + - "1.18.x" + - "1.17.x" + - "1.16.x" + - "1.12.x" + - 其他(请在描述中说明) + validations: + required: true + + - type: dropdown + id: mod-loader + attributes: + label: Mod 加载器 + description: 您是否使用了 Mod 加载器? + options: + - 无(原版) + - Fabric + - Forge + - 不适用 + validations: + required: true + + - type: input + id: java-version + attributes: + label: Java 版本 + description: 您使用的 Java 版本是? + placeholder: "例如:Java 21.0.1, Java 17.0.9, Java 8u381" + validations: + required: true + + - type: dropdown + id: java-source + attributes: + label: Java 来源 + description: 您的 Java 来自哪里? + options: + - DropOut 自动检测 + - 通过 DropOut 下载 + - 手动安装 (Oracle) + - 手动安装 (Sdkman) + - 手动安装 (Homebrew) + - 手动安装 (Chocolatey) + - 手动安装 (其他) + - 未知 + validations: + required: false + + - type: textarea + id: description + attributes: + label: 问题描述 + description: 清晰简洁地描述这个 Bug 是什么 + placeholder: 描述您遇到的问题... + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: 复现步骤 + description: 复现该问题的步骤 + placeholder: | + 1. 打开 '...' + 2. 点击 '...' + 3. 滚动到 '...' + 4. 出现错误 + validations: + required: true + + - type: textarea + id: expected + attributes: + label: 预期行为 + description: 您期望发生什么? + placeholder: 描述您期望发生的事情... + validations: + required: true + + - type: textarea + id: actual + attributes: + label: 实际行为 + description: 实际发生了什么? + placeholder: 描述实际发生的事情... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: 日志和错误信息 + description: | + 请从 DropOut 启动器终端上传日志文件,然后在此粘贴日志链接。 + + **如何上传日志:** + 1. 在 DropOut 启动器中打开终端 + 2. 使用内置的日志上传功能 + 3. 复制生成的日志链接并粘贴到下方 + + 如果无法使用自动上传,可以手动从以下位置获取日志: + - **macOS**: `$HOME/Library/Application Support/com.dropout.launcher/logs` + - **Linux**: `$HOME/.local/share/com.dropout.launcher` + placeholder: | + 粘贴日志链接或日志内容... + validations: + required: false + + - type: textarea + id: screenshots + attributes: + label: 截图或录屏 + description: | + 如果适用,请添加截图或录屏以帮助说明您的问题。 + + **截图要求:** + - 使用系统自带截图工具(Windows: `Win+Shift+S`, macOS: `Cmd+Shift+4`, Linux: `gnome-screenshot`/`spectacle`) + - 不要使用 QQ/微信/Discord 截图工具,它们可能改变分辨率和纵横比 + - 提供无遮挡的原始图片,避免窗口边框或覆盖层 + - 对于游戏问题,请捕获实际游戏窗口 + - 保持原始分辨率和 DPI + + 可以在上传前压缩大文件。 + placeholder: 拖放图片到这里或粘贴图片 URL + validations: + required: false + + - type: textarea + id: additional + attributes: + label: 其他信息 + description: 在此添加关于该问题的任何其他信息 + placeholder: 任何可能有帮助的额外信息... + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/cn-feature-request.yml b/.github/ISSUE_TEMPLATE/cn-feature-request.yml new file mode 100644 index 0000000..366a533 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/cn-feature-request.yml @@ -0,0 +1,118 @@ +name: 功能请求(中文) +description: 为 DropOut 提出新功能或改进建议 +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + 感谢您提出新功能建议!请填写以下表单以帮助我们理解您的需求。 + + - type: checkboxes + id: prerequisites + attributes: + label: 前置确认 + description: 请确认自己完成了下列项目之后再进行勾选,若勾选了"我未仔细阅读"选项将视为自愿接受被直接关闭 Issue + options: + - label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息 + required: false + - label: 我未仔细阅读这些内容,只是一键已读所有内容,并相信这不会影响问题的处理 + required: false + - label: 我已搜索现有 Issue,确认这不是重复的功能请求 + required: false + - label: 我已查看 README 中的 Roadmap + required: false + - label: 这个功能将使多个用户受益,而不仅仅是我自己 + required: false + + - type: dropdown + id: feature-type + attributes: + label: 功能类型 + description: 您请求的是哪种类型的功能? + options: + - UI/UX 改进 + - 游戏管理 + - Mod 加载器支持 + - 账户管理 + - Java 管理 + - 性能优化 + - 新平台支持 + - 文档 + - 其他 + validations: + required: true + + - type: textarea + id: problem + attributes: + label: 问题陈述 + description: 您的功能请求是否与某个问题相关?请描述。 + placeholder: "我总是对...感到困扰" + validations: + required: true + + - type: textarea + id: solution + attributes: + label: 建议的解决方案 + description: 描述您希望看到的解决方案 + placeholder: 清晰简洁地描述您希望发生的事情... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: 替代方案 + description: 描述您考虑过的任何替代解决方案或功能 + placeholder: 是否有其他方法可以解决这个问题? + validations: + required: false + + - type: textarea + id: examples + attributes: + label: 示例和参考 + description: | + 提供其他启动器或应用程序实现类似功能的示例 + placeholder: | + - MultiMC 通过...实现 + - Prism Launcher 有类似功能... + - 可以像 [示例链接] 这样工作... + validations: + required: false + + - type: dropdown + id: priority + attributes: + label: 优先级 + description: 这个功能对您有多重要? + options: + - 关键(阻碍正常使用) + - 高(显著改善体验) + - 中(锦上添花) + - 低(小便利) + validations: + required: true + + - type: checkboxes + id: contribution + attributes: + label: 贡献意愿 + description: 您是否愿意为这个功能做出贡献? + options: + - label: 我愿意实现这个功能 + - label: 我愿意帮助测试这个功能 + - label: 我可以提供设计草图或规范 + + - type: textarea + id: additional + attributes: + label: 其他信息 + description: 在此添加关于功能请求的任何其他信息、草图或截图 + placeholder: 任何可能有帮助的额外信息... + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/cn-question.yml b/.github/ISSUE_TEMPLATE/cn-question.yml new file mode 100644 index 0000000..b7422e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/cn-question.yml @@ -0,0 +1,87 @@ +name: 问题咨询(中文) +description: 询问关于使用 DropOut 的问题 +title: "[Question]: " +labels: ["question"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + 有关于使用 DropOut 的问题吗?请填写以下表单。 + + **注意:** 如果是 Bug 报告,请使用 Bug 反馈模板。 + + - type: checkboxes + id: prerequisites + attributes: + label: 前置确认 + description: 请确认自己完成了下列项目之后再进行勾选,若勾选了"我未仔细阅读"选项将视为自愿接受被直接关闭 Issue + options: + - label: 我理解 Issue 是用于提问和获取帮助的,而非吐槽评论区 + required: false + - label: 我未仔细阅读这些内容,只是一键已读所有内容,并相信这不会影响问题的处理 + required: false + - label: 我已搜索现有 Issue,查看是否有人已经回答了我的问题 + required: false + - label: 我已阅读 README 和文档 + required: false + + - type: dropdown + id: category + attributes: + label: 问题类别 + description: 您的问题是关于什么的? + options: + - 安装和设置 + - 账户和认证 + - Minecraft 版本管理 + - Mod 加载器(Fabric/Forge) + - Java 安装和配置 + - 游戏启动问题 + - 设置和配置 + - 从源代码构建 + - 为项目做贡献 + - 其他 + validations: + required: true + + - type: textarea + id: question + attributes: + label: 您的问题 + description: 请详细描述您的问题 + placeholder: 您想了解什么? + validations: + required: true + + - type: input + id: version + attributes: + label: DropOut 版本(如果适用) + description: 您正在使用的 DropOut 版本是? + placeholder: "例如:0.1.23" + validations: + required: false + + - type: dropdown + id: os + attributes: + label: 操作系统(如果适用) + description: 您使用的操作系统是? + options: + - Windows + - macOS + - Linux + - 不适用 + validations: + required: false + + - type: textarea + id: context + attributes: + label: 其他信息 + description: 添加任何其他可能帮助我们回答您问题的上下文、截图或信息 + placeholder: 任何额外的细节... + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b2dbc20 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://dropout.hydroroll.team + about: Read the project documentation and guides + - name: Discussions + url: https://github.com/HsiangNianian/DropOut/discussions + about: Ask questions and discuss ideas with the community + - name: Releases + url: https://github.com/HsiangNianian/DropOut/releases + about: Download the latest version or view changelog diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..38cb216 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,118 @@ +name: Feature Request +description: Suggest a new feature or enhancement for DropOut +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Thank you for suggesting a new feature! Please fill out the form below to help us understand your request. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + description: Please confirm you have completed the following before submitting. Issues that check "I have not read carefully" may be closed immediately. + options: + - label: I understand that Issues are for feedback and problem-solving, not for complaints. I will provide as much information as possible. + required: false + - label: I have not read carefully and just clicked through everything, believing this won't affect issue handling. + required: false + - label: I have searched existing issues to ensure this is not a duplicate + required: false + - label: I have checked the roadmap in the README + required: false + - label: This feature would benefit multiple users, not just myself + required: false + + - type: dropdown + id: feature-type + attributes: + label: Feature Type + description: What type of feature are you requesting? + options: + - UI/UX Enhancement + - Game Management + - Mod Loader Support + - Account Management + - Java Management + - Performance Improvement + - New Platform Support + - Documentation + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: Is your feature request related to a problem? Please describe. + placeholder: "I'm always frustrated when..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like to see + placeholder: A clear and concise description of what you want to happen... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternative Solutions + description: Describe any alternative solutions or features you've considered + placeholder: Are there other ways to solve this problem? + validations: + required: false + + - type: textarea + id: examples + attributes: + label: Examples and References + description: | + Provide examples from other launchers or applications that implement similar features + placeholder: | + - MultiMC does this by... + - Prism Launcher has a similar feature... + - This could work like [example link]... + validations: + required: false + + - type: dropdown + id: priority + attributes: + label: Priority + description: How important is this feature to you? + options: + - Critical (blocks normal usage) + - High (significantly improves experience) + - Medium (nice to have) + - Low (minor convenience) + validations: + required: true + + - type: checkboxes + id: contribution + attributes: + label: Contribution + description: Would you be willing to contribute to this feature? + options: + - label: I would be willing to implement this feature + - label: I would be willing to help test this feature + - label: I can provide design mockups or specifications + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context, mockups, or screenshots about the feature request here + placeholder: Any additional information that might be helpful... + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..ac170ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,87 @@ +name: Question +description: Ask a question about using DropOut +title: "[Question]: " +labels: ["question"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Have a question about using DropOut? Please fill out the form below. + + **Note:** For bug reports, please use the Bug Report template instead. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + description: Please confirm you have completed the following before submitting. Issues that check "I have not read carefully" may be closed immediately. + options: + - label: I understand that Issues are for asking questions and getting help, not for complaints. + required: false + - label: I have not read carefully and just clicked through everything, believing this won't affect issue handling. + required: false + - label: I have searched existing issues to see if my question has been answered + required: false + - label: I have read the README and documentation + required: false + + - type: dropdown + id: category + attributes: + label: Question Category + description: What is your question about? + options: + - Installation and Setup + - Account and Authentication + - Minecraft Version Management + - Mod Loaders (Fabric/Forge) + - Java Installation and Configuration + - Game Launch Issues + - Settings and Configuration + - Building from Source + - Contributing to the Project + - Other + validations: + required: true + + - type: textarea + id: question + attributes: + label: Your Question + description: Please describe your question in detail + placeholder: What would you like to know? + validations: + required: true + + - type: input + id: version + attributes: + label: DropOut Version (if applicable) + description: What version of DropOut are you using? + placeholder: "e.g., 0.1.23" + validations: + required: false + + - type: dropdown + id: os + attributes: + label: Operating System (if applicable) + description: Which operating system are you using? + options: + - Windows + - macOS + - Linux + - Not applicable + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional Context + description: Add any other context, screenshots, or information that might help us answer your question + placeholder: Any additional details... + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE/cn-pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/cn-pull_request_template.md new file mode 100644 index 0000000..abcbaa1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/cn-pull_request_template.md @@ -0,0 +1,127 @@ +# 描述 + +<!-- 简要描述此 PR 的更改内容 --> + +## 更改类型 + +<!-- 用 "x" 标记相关选项 --> + +- [ ] Bug 修复(修复问题的非破坏性更改) +- [ ] 新功能(添加功能的非破坏性更改) +- [ ] 破坏性更改(会导致现有功能无法正常工作的修复或功能) +- [ ] 文档更新 +- [ ] UI/UX 改进 +- [ ] 性能优化 +- [ ] 代码重构(无功能性更改) +- [ ] 配置更改 +- [ ] 测试添加或更新 + +## LLM 生成代码声明 + +<!-- 如果此 PR 包含 LLM 生成的代码,请在此声明 --> + +- [ ] 此 PR 包含 LLM 生成的代码,我**提供**质量担保 +- [ ] 此 PR 包含 LLM 生成的代码,我**不提供**质量担保 +- [ ] 此 PR 不包含 LLM 生成的代码 + +## 相关 Issue + +<!-- 使用 #issue_number 链接相关 issue --> + +关闭 # +相关 # + +## 更改内容 + +<!-- 详细描述更改内容 --> + +### 后端 (Rust) + +- + +### 前端 (Svelte) + +- + +### 配置 + +- + +## 测试 + +<!-- 描述你运行的测试以及如何复现 --> + +### 测试环境 + +- **操作系统**:<!-- 例如:Windows 11、macOS 14、Ubuntu 22.04 --> +- **DropOut 版本**:<!-- 例如:0.1.23 --> +- **测试的 Minecraft 版本**:<!-- 例如:1.21.1 --> +- **Mod 加载器**:<!-- 例如:Fabric 0.16.0、Forge 49.0.3 或 无 --> + +### 测试用例 + +- [ ] 已在 Windows 上测试 +- [ ] 已在 macOS 上测试 +- [ ] 已在 Linux 上测试 +- [ ] 已测试原版 Minecraft +- [ ] 已测试 Fabric +- [ ] 已测试 Forge +- [ ] 已测试游戏启动 +- [ ] 已测试登录流程 +- [ ] 已测试 Java 检测/下载 + +### 测试步骤 + +1. +2. +3. + +## 检查清单 + +<!-- 用 "x" 标记已完成的项目 --> + +### 代码质量 + +- [ ] 我的代码遵循项目的代码风格指南 +- [ ] 我已对自己的代码进行了自审 +- [ ] 我已对难以理解的区域添加了注释 +- [ ] 我的更改没有产生新的警告或错误 + +### 测试验证 + +- [ ] 我已在本地测试了我的更改 +- [ ] 我已添加测试来证明我的修复有效或功能正常工作 +- [ ] 新的和现有的单元测试在本地通过 +- [ ] 我至少在一个目标平台上进行了测试 + +### 文档更新 + +- [ ] 我已相应地更新了文档 +- [ ] 如有需要,我已更新 README +- [ ] 我已在必要处添加/更新代码注释 + +### 依赖项 + +- [ ] 我已检查没有添加不必要的依赖项 +- [ ] 所有新依赖项都已正确记录 +- [ ] `Cargo.lock` 和/或 `pnpm-lock.yaml` 已更新(如果依赖项有变化) + +## 截图 / 视频 + +<!-- 如适用,添加截图或视频来展示更改 --> + +## 附加说明 + +<!-- 在此添加关于此 PR 的其他上下文 --> + +## 破坏性更改说明 + +<!-- 如果这是破坏性更改,请描述用户的迁移路径 --> + +--- + +**维护者专用:** + +- [ ] 代码审查已完成 +- [ ] CI 检查通过 +- [ ] 准备合并 diff --git a/.github/PULL_REQUEST_TEMPLATE/en-pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/en-pull_request_template.md new file mode 100644 index 0000000..79a8148 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/en-pull_request_template.md @@ -0,0 +1,127 @@ +# Description + +<!-- Provide a brief description of the changes in this PR --> + +## Type of Change + +<!-- Mark the relevant option with an "x" --> + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] UI/UX improvement +- [ ] Performance improvement +- [ ] Code refactoring (no functional changes) +- [ ] Configuration change +- [ ] Test addition or update + +## LLM-Generated Code Disclosure + +<!-- If this PR contains LLM-generated code, please disclose it here --> + +- [ ] This PR contains LLM-generated code, and I **provide** quality assurance +- [ ] This PR contains LLM-generated code, and I **do not provide** quality assurance +- [ ] This PR does not contain LLM-generated code + +## Related Issues + +<!-- Link to related issues using #issue_number --> + +Closes # +Related to # + +## Changes Made + +<!-- Describe the changes in detail --> + +### Backend (Rust) + +- + +### Frontend (Svelte) + +- + +### Configuration + +- + +## Testing + +<!-- Describe the tests you ran and how to reproduce them --> + +### Test Environment + +- **OS**: <!-- e.g., Windows 11, macOS 14, Ubuntu 22.04 --> +- **DropOut Version**: <!-- e.g., 0.1.23 --> +- **Minecraft Version Tested**: <!-- e.g., 1.21.1 --> +- **Mod Loader**: <!-- e.g., Fabric 0.16.0, Forge 49.0.3, or N/A --> + +### Test Cases + +- [ ] Tested on Windows +- [ ] Tested on macOS +- [ ] Tested on Linux +- [ ] Tested with vanilla Minecraft +- [ ] Tested with Fabric +- [ ] Tested with Forge +- [ ] Tested game launch +- [ ] Tested authentication flow +- [ ] Tested Java detection/download + +### Steps to Test + +1. +2. +3. + +## Checklist + +<!-- Mark completed items with an "x" --> + +### Code Quality + +- [ ] 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 or errors + +### Testing Verification + +- [ ] I have tested my changes locally +- [ ] 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 +- [ ] I have tested on at least one target platform + +### Documentation + +- [ ] I have updated the documentation accordingly +- [ ] I have updated the README if needed +- [ ] I have added/updated code comments where necessary + +### Dependencies + +- [ ] I have checked that no unnecessary dependencies were added +- [ ] All new dependencies are properly documented +- [ ] `Cargo.lock` and/or `pnpm-lock.yaml` are updated (if dependencies changed) + +## Screenshots / Videos + +<!-- If applicable, add screenshots or videos to demonstrate the changes --> + +## Additional Notes + +<!-- Add any other context about the PR here --> + +## Breaking Changes + +<!-- If this is a breaking change, describe the migration path for users --> + +--- + +**For Maintainers:** + +- [ ] Code review completed +- [ ] CI checks passing +- [ ] Ready to merge diff --git a/.github/agents/commit.agent.md b/.github/agents/commit.agent.md new file mode 100644 index 0000000..7187402 --- /dev/null +++ b/.github/agents/commit.agent.md @@ -0,0 +1,260 @@ +# Commit Helper Agent + +You are a Git commit message assistant following the Conventional Commits specification. + +## Task + +Generate well-structured commit messages based on staged changes or user descriptions. + +## Commit Format + +``` +<type>[optional scope]: <description> + +[optional body] + +[optional footer(s)] +``` + +## Workflow Rules + +### Language Policy + +1. **Commit message language**: ALWAYS write in **English** unless user explicitly requests another language +2. **Explanation language**: Use the **same language as user's request** +3. **Translation rule**: If commit language ≠ user's language → provide explanation + - User speaks Chinese + English commit → Explain in Chinese + - User speaks English + Chinese commit → Explain in English + - User speaks English + English commit → No extra explanation needed + - User speaks Chinese + Chinese commit → No extra explanation needed + +### Confirmation Policy + +**ALWAYS ask for confirmation before committing** unless user explicitly says: +- "commit directly" +- "commit immediately" +- "just commit it" + +**Standard flow**: +1. Generate commit message +2. Explain what it means (in user's language if different from English) +3. Show the command: `git commit -m "..."` +4. Ask: "Proceed with this commit?" (in user's language) +5. Only execute if user confirms + +### Step 0: Check Current Branch (REQUIRED) + +**Before doing anything**, check the current branch and validate: + +1. Run `git branch --show-current` to get current branch name +2. Run `git status` to see if there are any changes +3. **Validate branch naming**: + - Feature work → Should be on `feat/*` or `feature/*` branch + - Bug fixes → Should be on `fix/*` or `bugfix/*` branch + - Documentation → Should be on `docs/*` branch + - Refactoring → Should be on `refactor/*` branch + - Hotfix → Should be on `hotfix/*` branch + +4. **Branch validation rules**: + - If on `main` or `master` → WARN: "You're on the main branch. Consider creating a feature branch first." + - If branch name doesn't match change type → WARN: "Current branch is `X`, but changes look like `Y` type. Continue or switch branch?" + - If branch name matches change type → Proceed silently + +**Example warnings**: +``` +On main + adding feature: + "You're on main branch. Consider: git checkout -b feat/your-feature-name" + +On feat/ui-update + fixing bug: + "Current branch is feat/ui-update but changes look like a bug fix. + Consider: git checkout -b fix/bug-name or continue on current branch?" + +On docs/readme + adding code: + "Current branch is docs/readme but changes include code modifications. + Consider switching to feat/* or fix/* branch?" +``` + +If user chooses to continue, proceed to generate commit message as normal. + +### Step 1: Analyze Changes + +When user asks for a commit message: + +1. **If changes are staged**: Run `git diff --cached --stat` to see what files changed +2. **If specific files mentioned**: Run `git diff <file>` to understand the changes +3. **If user describes changes**: Use their description directly + +### Step 2: Determine Type + +| Type | When to Use | +|------|-------------| +| `feat` | New feature for the user | +| `fix` | Bug fix | +| `docs` | Documentation only changes | +| `style` | Formatting, missing semicolons, etc. (no code change) | +| `refactor` | Code change that neither fixes a bug nor adds a feature | +| `perf` | Performance improvement | +| `test` | Adding or updating tests | +| `build` | Changes to build system or dependencies | +| `ci` | CI configuration changes | +| `chore` | Other changes that don't modify src or test files | +| `revert` | Reverts a previous commit | + +**Quick Decision Tree**: +``` +Changes involve... +├─ New user-facing feature? → feat +├─ Fix user-reported bug? → fix +├─ Only docs/comments? → docs +├─ Internal refactor? → refactor +├─ Performance improvement? → perf +└─ Breaking API change? → Add ! + BREAKING CHANGE footer +``` + +### Step 3: Determine Scope (Optional) + +Scope should be a noun describing the section of codebase: +- `feat(gui)`: GUI-related feature +- `fix(memory)`: Memory-related fix +- `docs(api)`: API documentation +- `refactor(core)`: Core module refactoring + +### Step 4: Write Description + +- Use imperative mood: "add" not "added" or "adds" +- Don't capitalize first letter +- No period at the end +- Keep under 50 characters + +**Common mistakes**: +- ❌ `Added new feature` → ✅ `add new feature` +- ❌ `Fix bug.` → ✅ `fix authentication issue` +- ❌ Multiple concerns → Split into separate commits + +### Step 5: Add Body (If Needed) + +- Explain WHAT and WHY, not HOW +- Wrap at 72 characters +- Separate from description with blank line + +### Step 6: Add Footer (If Needed) + +**Breaking Changes**: +``` +BREAKING CHANGE: <description> +``` + +**AI-Generated Commits** (REQUIRED for AI assistance): +``` +Reviewed-by: [MODEL_NAME] +``` + +**Issue References**: +``` +Refs #123 +Closes #456 +``` + +## Examples + +### Simple Feature +``` +feat(gui): add transparent window support +``` + +### Bug Fix with Body +``` +fix(memory): resolve index memory leak + +The index was not being properly released when switching +between different memory contexts, causing gradual memory +growth over extended sessions. + +Reviewed-by: [MODEL_NAME] +``` + +### Breaking Change +``` +refactor(core)!: restructure plugin system + +Plugin API now requires explicit registration instead of +auto-discovery. This improves startup time but requires +migration of existing plugins. + +BREAKING CHANGE: Plugin API signature changed from +`register()` to `register(manifest: PluginManifest)` + +Reviewed-by: [MODEL_NAME] +``` + +### Documentation Update +``` +docs: update PRD with new interaction flow + +Reviewed-by: [MODEL_NAME] +``` + +### Multiple Changes (use most significant type) +``` +feat(state): add mood decay system with persistence + +- Implement time-based mood decay algorithm +- Add SQLite persistence for mood state +- Create mood recovery mechanics + +Reviewed-by: [MODEL_NAME] +Refs #42 +``` + +## Commands + +When user says "create commit" or "commit message": + +1. **[REQUIRED]** Check current branch and validate (Step 0) +2. Ask what changes to commit (or analyze staged changes) +3. Generate commit message in **English** (following Conventional Commits format) +4. **If commit language is not user's language**: Explain the commit in user's language +5. **Always ask for confirmation** before executing (unless user said "commit directly" or similar) +6. If confirmed, run: `git commit -m "<message>"` + +**Example 1 (Chinese user, English commit)**: +``` +User: "帮我提交这个功能" +AI: "建议的提交信息: + feat(gui): add transparent window support + + 这个提交添加了透明窗口功能。是否执行此提交?" +User: "是" or "直接提交" or "y" +AI: [executes git commit] +``` + +**Example 2 (English user, Chinese commit)**: +``` +User: "write a Chinese commit for this feature" +AI: "Suggested commit: + feat(gui): 添加透明窗口支持 + + This commit adds transparent window support. Proceed?" +User: "yes" or "commit directly" or "y" +AI: [executes git commit] +``` + +**Example 3 (English user, English commit - no explanation)**: +``` +User: "commit this feature" +AI: "Suggested commit: + feat(gui): add transparent window support + + Proceed with this commit?" +User: "yes" or "commit directly" or "y" +AI: [executes git commit] +``` + +When user says "amend commit": +```bash +git commit --amend -m "<new message>" +``` + +## References + +- Commit spec: <a>.github/references/git/conventional-commit.md</a> diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6b577d4 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,289 @@ +# DropOut Minecraft Launcher - AI Development Guide + +## Architecture Overview + +**DropOut** is a Tauri v2 desktop application combining: +- **Backend (Rust)**: Game launching, asset management, authentication, mod loader installation +- **Frontend (Svelte 5)**: Reactive UI with Tailwind CSS 4 and particle effects +- **Communication**: Tauri commands (invoke) and events (emit/listen) +- **Pre-commit Hooks**: Python-based tooling for JSON/TOML validation (managed via `pyproject.toml`) + +**Key Data Flow**: Frontend invokes Rust commands → Rust processes/downloads → Rust emits progress events → Frontend updates UI via listeners + +**Version Management**: Uses `_version.py` for single source of truth, synced to `Cargo.toml` (0.1.24) and `tauri.conf.json` + +## Project Structure + +``` +src-tauri/ # Rust backend + src/ + main.rs # Tauri commands, game launch logic, event emissions + core/ # Core modules (auth, downloader, fabric, forge, java, etc.) + mod.rs # Module declarations + auth.rs # Microsoft OAuth + offline auth via Device Code Flow + downloader.rs # Concurrent downloads with progress tracking, resumable downloads + fabric.rs # Fabric loader installation and version management + forge.rs # Forge installer execution and profile generation + java.rs # Java detection, Adoptium download/install, catalog management + config.rs # LauncherConfig (memory, java path, download threads) + game_version.rs # Minecraft version JSON parsing + manifest.rs # Mojang version manifest fetching + maven.rs # Maven artifact URL resolution for mod loaders + rules.rs # OS/feature rule evaluation for libraries + version_merge.rs # Parent version inheritance merging + utils/ + zip.rs # Native library extraction +ui/ # Svelte 5 frontend + src/ + App.svelte # Main app component, enforces dark mode + stores/ # Svelte 5 runes state management ($state, $effect) + auth.svelte.ts # Authentication state with device code polling + game.svelte.ts # Game state (running, logs) + settings.svelte.ts # Settings + Java detection + ui.svelte.ts # UI state (toasts, modals, active view) + components/ # UI components (HomeView, VersionsView, SettingsView, etc.) + lib/ # Reusable components (DownloadMonitor, GameConsole) +``` + +## Critical Development Workflows + +### Development Mode +```bash +cargo tauri dev # Starts frontend dev server (Vite on :5173) + Tauri window +``` +- Frontend uses **Rolldown-based Vite fork** (`npm:rolldown-vite@7.2.5`) with hot reload +- Backend recompiles on Rust file changes +- Console shows both Rust stdout and frontend Vite logs +- **Vite Config**: Uses `usePolling: true` for watch compatibility with Tauri +- **HMR**: WebSocket on `ws://localhost:5173` + +### Pre-commit Checks +- Uses **pre-commit** with Python (configured in `pyproject.toml`) +- Hooks: JSON/TOML/YAML validation, Ruff for Python files +- Run manually: `pre-commit run --all-files` +- **IMPORTANT**: All Python tooling for CI/validation lives here, NOT for app logic + +### Building +```bash +cd ui && pnpm install # Install frontend dependencies (requires pnpm 9, Node 22) +cargo tauri build # Produces platform bundles in src-tauri/target/release/bundle/ +``` + +### Frontend Workflows +```bash +cd ui +pnpm check # Svelte type checking + TypeScript validation +pnpm lint # OxLint for code quality +pnpm format # OxFmt for formatting (--check for CI) +``` + +### Testing +- CI workflow: [`.github/workflows/test.yml`](.github/workflows/test.yml) tests on Ubuntu, Arch (Wayland), Windows, macOS +- Local: `cargo test` (no comprehensive test suite exists yet) +- **Test workflow behavior**: Push/PR = Linux build only, `workflow_dispatch` = full multi-platform builds + +## Project-Specific Patterns & Conventions + +### Tauri Command Pattern +Commands in [`main.rs`](../src-tauri/src/main.rs) follow this structure: +```rust +#[tauri::command] +async fn command_name( + window: Window, + state: State<'_, SomeState>, + param: Type, +) -> Result<ReturnType, String> { + emit_log!(window, "Status message"); // Emits "launcher-log" event + // ... async logic + Ok(result) +} +``` +**Register in `main()`:** +```rust +tauri::Builder::default() + .invoke_handler(tauri::generate_handler![command_name, ...]) +``` + +### Event Communication +**Rust → Frontend (Progress/Logs):** +```rust +// In Rust +window.emit("launcher-log", "Downloading assets...")?; +window.emit("download-progress", progress_struct)?; +``` +```typescript +// In Frontend (Svelte) +import { listen } from "@tauri-apps/api/event"; +const unlisten = await listen("launcher-log", (event) => { + console.log(event.payload); +}); +``` + +**Frontend → Rust (Commands):** +```typescript +import { invoke } from "@tauri-apps/api/core"; +const result = await invoke("start_game", { versionId: "1.20.4" }); +``` + +### State Management (Rust) +Global state via Tauri's managed state: +```rust +pub struct ConfigState { + pub config: Mutex<LauncherConfig>, + pub file_path: PathBuf, +} +// In main: +.manage(ConfigState::new(&app_handle)) +// In commands: +config_state: State<'_, ConfigState> +``` + +### State Management (Svelte 5) +Uses **Svelte 5 runes** (not stores): +```typescript +// stores/auth.svelte.ts +export class AuthState { + currentAccount = $state<Account | null>(null); // Reactive state + isLoginModalOpen = $state(false); + + $effect(() => { // Side effects + // Runs when dependencies change + }); +} +// Export singleton +export const authState = new AuthState(); +``` +**CRITICAL**: Stores are TypeScript classes with `$state` runes, not Svelte 4's `writable()`. Each store file exports a singleton instance. + +**Store Pattern**: +- File: `stores/*.svelte.ts` (note `.svelte.ts` extension) +- Class-based with reactive `$state` properties +- Methods for actions (async operations with `invoke()`) +- Derived values with `get` accessors +- Side effects with `$effect()` (auto-tracks dependencies) + +### Version Inheritance System +Modded versions (Fabric/Forge) use `inheritsFrom` field: +- [`version_merge.rs`](../src-tauri/src/core/version_merge.rs): Merges parent vanilla JSON with mod loader JSON +- [`manifest.rs`](../src-tauri/src/core/manifest.rs): `load_version()` recursively resolves inheritance +- Libraries, assets, arguments are merged from parent + modded version + +### Microsoft Authentication Flow +Uses **Device Code Flow** (no redirect needed): +1. Frontend calls `start_microsoft_login()` → gets device code + URL +2. User visits URL in browser, enters code +3. Frontend polls `complete_microsoft_login()` with device code +4. Rust exchanges code → MS token → Xbox Live → XSTS → Minecraft token +5. Stores MS refresh token for auto-refresh (see [`auth.rs`](../src-tauri/src/core/auth.rs)) + +**Client ID**: Uses ATLauncher's public client ID (`c36a9fb6-4f2a-41ff-90bd-ae7cc92031eb`) + +### Download System +[`downloader.rs`](../src-tauri/src/core/downloader.rs) features: +- **Concurrent downloads** with semaphore (configurable threads) +- **Resumable downloads**: `.part` + `.part.meta` files track progress +- **Multi-segment downloads**: Large files split into segments downloaded in parallel +- **Checksum verification**: SHA1/SHA256 validation +- **Progress events**: Emits `download-progress` with file name, bytes, ETA +- **Queue persistence**: Java downloads saved to `download_queue.json` for resumption + +### Java Management +[`java.rs`](../src-tauri/src/core/java.rs): +- **Auto-detection**: Scans `/usr/lib/jvm`, `/Library/Java`, `JAVA_HOME`, `PATH` +- **Adoptium API**: Fetches available JDK/JRE versions for current OS/arch +- **Catalog caching**: `java_catalog.json` cached for 24 hours +- **Installation**: Downloads, extracts to `app_data_dir/java/<version>` +- **Cancellation**: Global `AtomicBool` flag for download cancellation + +### Error Handling +- Commands return `Result<T, String>` (String for JS-friendly errors) +- Use `.map_err(|e| e.to_string())` to convert errors +- Emit detailed error logs: `emit_log!(window, format!("Error: {}", e))` + +### File Paths +- **Game directory**: `app_handle.path().app_data_dir()` (~/.local/share/com.dropout.launcher on Linux) +- **Versions**: `game_dir/versions/<version_id>/<version_id>.json` +- **Libraries**: `game_dir/libraries/<maven-path>` +- **Assets**: `game_dir/assets/objects/<hash[0..2]>/<hash>` +- **Config**: `game_dir/config.json` +- **Accounts**: `game_dir/accounts.json` + +## Integration Points + +### External APIs +- **Mojang**: `https://piston-meta.mojang.com/mc/game/version_manifest_v2.json` +- **Fabric Meta**: `https://meta.fabricmc.net/v2/` +- **Forge Maven**: `https://maven.minecraftforge.net/` +- **Adoptium**: `https://api.adoptium.net/v3/` +- **GitHub Releases**: `https://api.github.com/repos/HsiangNianian/DropOut/releases` + +### Native Dependencies +- **Linux**: `libwebkit2gtk-4.1-dev`, `libgtk-3-dev` (see [test.yml](../.github/workflows/test.yml)) +- **macOS**: System WebKit via Tauri +- **Windows**: WebView2 runtime (bundled) + +## Common Tasks + +### Adding a New Tauri Command +1. Define function in [`main.rs`](../src-tauri/src/main.rs) with `#[tauri::command]` +2. Add to `.invoke_handler(tauri::generate_handler![..., new_command])` +3. Call from frontend: `invoke("new_command", { args })` + +### Adding a New UI View +1. Create component in `ui/src/components/NewView.svelte` +2. Import in [`App.svelte`](../ui/src/App.svelte) +3. Add navigation in [`Sidebar.svelte`](../ui/src/components/Sidebar.svelte) +4. Update `uiState.activeView` in [`ui.svelte.ts`](../ui/src/stores/ui.svelte.ts) + +### Emitting Progress Events +Use `emit_log!` macro for launcher logs: +```rust +emit_log!(window, format!("Downloading {}", filename)); +``` +For custom events: +```rust +window.emit("custom-event", payload)?; +``` + +### Handling Placeholders in Arguments +Game arguments may contain `${variable}` placeholders. Use the `has_unresolved_placeholder()` helper to skip malformed arguments (see [`main.rs:57-67`](../src-tauri/src/main.rs#L57-L67)). + +## Important Notes + +- **Dark mode enforced**: [`App.svelte`](../ui/src/App.svelte) force-adds `dark` class regardless of system preference +- **Svelte 5 syntax**: Use `$state`, `$derived`, `$effect` (not `writable` stores) +- **No CREATE_NO_WINDOW on non-Windows**: Use `#[cfg(target_os = "windows")]` for Windows-specific code +- **Version IDs**: Fabric uses `fabric-loader-<loader>-<game>`, Forge uses `<game>-forge-<loader>` +- **Mod loader libraries**: Don't have `downloads.artifact`, use Maven resolution via [`maven.rs`](../src-tauri/src/core/maven.rs) +- **Native extraction**: Extract to `versions/<version>/natives/`, exclude META-INF +- **Classpath order**: Libraries → Client JAR (see [`main.rs:437-453`](../src-tauri/src/main.rs#L437-L453)) +- **Version management**: Single source in `_version.py`, synced to Cargo.toml and tauri.conf.json +- **Frontend dependencies**: Must use pnpm 9 + Node 22 (uses Rolldown-based Vite fork) +- **Store files**: Must have `.svelte.ts` extension, not `.ts` + +## Debugging Tips + +- **Rust logs**: Check terminal running `cargo tauri dev` +- **Frontend logs**: Browser devtools (Ctrl+Shift+I in Tauri window) +- **Game logs**: Listen to `game-stdout`/`game-stderr` events +- **Download issues**: Check `download-progress` events, validate SHA1 hashes +- **Auth issues**: MS WAF blocks requests without User-Agent (see [`auth.rs:6-12`](../src-tauri/src/core/auth.rs#L6-L12)) + +## Version Compatibility + +- **Rust**: Edition 2021, requires Tauri v2 dependencies +- **Node.js**: 22+ with pnpm 9+ for frontend (uses Rolldown-based Vite fork `npm:rolldown-vite@7.2.5`) +- **Tauri**: v2.9+ +- **Svelte**: v5.46+ (runes mode) +- **Java**: Supports detection of Java 8-23+, recommends Java 17+ for modern Minecraft +- **Python**: 3.10+ for pre-commit hooks (validation only, not app logic) + +## Commit Conventions + +Follow instructions in [`.github/instructions/commit.instructions.md`](.github/instructions/commit.instructions.md): +- **Format**: `<type>[scope]: <description>` (lowercase, imperative, no period) +- **AI commits**: MUST include `Reviewed-by: [MODEL_NAME]` +- **Common types**: `feat`, `fix`, `docs`, `refactor`, `perf`, `test`, `chore` +- **Language**: Commit messages ALWAYS in English +- **Confirmation**: ALWAYS ask before committing (unless "commit directly" requested) +- See [Conventional Commits spec](.github/references/git/conventional-commit.md) for details diff --git a/.github/instructions/commit.instructions.md b/.github/instructions/commit.instructions.md new file mode 100644 index 0000000..f01f080 --- /dev/null +++ b/.github/instructions/commit.instructions.md @@ -0,0 +1,38 @@ +--- +applyTo: "**" +--- + +# Commit Helper Instructions + +When user requests commit help → Follow <a>.github/agents/commit.agent.md</a> + +## Critical Rules + +1. **Language**: Commit message ALWAYS in **English** (unless user specifies otherwise) +2. **Explanation**: Use **user's request language** ONLY when commit language differs + - Chinese user + English commit → Explain in Chinese + - English user + Chinese commit → Explain in English + - Same language → No extra explanation needed +3. **Confirmation**: ALWAYS ask before committing (unless "commit directly" requested) + +## Quick Reference + +**Format**: `<type>[scope]: <description>` + +**Common types**: `feat` `fix` `docs` `refactor` `perf` `test` `chore` + +**AI commits MUST include**: `Reviewed-by: [MODEL_NAME]` + +**Spec**: <a>.github/references/git/conventional-commit.md</a> + +## Common Mistakes + +| Wrong | Right | +|-------|-------| +| `feat: Added feature` | `feat: add feature` (imperative) | +| `Fix bug.` | `fix: resolve auth issue` (lowercase, no period) | +| `feat: add A, refactor B, update C` | Split into 3 commits | + +## User Triggers + +"create commit", "commit message", "conventional commit" diff --git a/.github/references/git/conventional-commit.md b/.github/references/git/conventional-commit.md new file mode 100644 index 0000000..a9e27bc --- /dev/null +++ b/.github/references/git/conventional-commit.md @@ -0,0 +1,153 @@ +# What is Conventional Commits? + +> A standard for writing commit messages. + +## Table of Contents + +- [What is Conventional Commits?](#what-is-conventional-commits) +- [Examples](#examples) +- [Rules](#rules) +- [Why Should We Use It?](#why-should-we-use-it) +- [Changelog Generation](#changelog-generation) +- [Bump the Version Precisely](#bump-the-version-precisely) +- [How About Squash Merges?](#how-about-squash-merges) + +The Conventional Commits specification is a lightweight convention on top of commit messages. It provides an easy set of rules for creating an explicit commit history; which makes it easier to write automated tools on top of. This convention dovetails with [SemVer](https://semver.org/), by describing the features, fixes, and breaking changes made in commit messages. + +```text +<type>[optional scope]: <description> + +[optional body] + +[optional footer(s)] +``` + +## Examples + +> Commit message with scope + +```text +feat(SHOPPER-000): introduce OrderMonitor v2 +``` + +> Commit message with breaking change + +```text +refactor!: drop support for Node 6 +``` + +> Commit message with footer and breaking change + +```text +feat: enhance error handling + +BREAKING CHANGE: Modified every error message and added the error key +``` + +> Commit message with multi-paragraph body and multiple footers + +```text +fix(SHOPPER-000): correct minor typos in code + +see the issue for details + +on typos fixed. + +Reviewed-by: Z +Refs #133 +``` + +> Here reviewer should be replaced with the actual name of the reviewer or the **model name card** like "GPT-4o mini" by default. + +## Rules + +The key words **“MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL”** in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt). + +- Commits MUST be prefixed with a type, which consists of a noun, feat, fix, etc., followed by the OPTIONAL scope, OPTIONAL !, and REQUIRED terminal colon and space. +- The type feat MUST be used when a commit adds a new feature to your application or library. +- The type fix MUST be used when a commit represents a bug fix for your application. +- A scope MAY be provided after a type. A scope MUST consist of a noun describing a section of the codebase surrounded by parenthesis, e.g., fix(parser): +- A description MUST immediately follow the colon and space after the type/scope prefix. The description is a short summary of the code changes, e.g., fix: array parsing issue when multiple spaces were contained in string. +- A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description. +- A commit body is free-form and MAY consist of any number of newline separated paragraphs. +- One or more footers MAY be provided one blank line after the body. Each footer MUST consist of a word token, followed by either a :<space> or <space># separator, followed by a string value (this is inspired by the git trailer convention). +- A footer’s token MUST use - in place of whitespace characters, e.g., Acked-by (this helps differentiate the footer section from a multi-paragraph body). An exception is made for BREAKING CHANGE, which MAY also be used as a token. +- A footer’s value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer token/separator pair is observed. +- Breaking changes MUST be indicated in the type/scope prefix of a commit, or as an entry in the footer. +- If included as a footer, a breaking change MUST consist of the uppercase text BREAKING CHANGE, followed by a colon, space, and description, e.g., BREAKING CHANGE: environment variables now take precedence over config files. +If included in the type/scope prefix, breaking changes MUST be indicated by a ! immediately before the :. If ! is used, BREAKING CHANGE: MAY be omitted from the footer section, and the commit description SHALL be used to describe the breaking change. +- Types other than feat and fix MAY be used in your commit messages, e.g., docs: updated ref docs. +- The units of information that make up Conventional Commits MUST NOT be treated as case sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase. +- BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer. + +## Why Should We Use It? + +- Automatic changelog generation +- The context information that refers to business initiative when it's needed +- Noticeable breaking changes +- Forces us to write better commit messages (no more vague 'fix', 'refactor' commits) +- Bumping version precisely +- Making it easier to contribute to the projects by allowing contributors to explore a more structured commit history + +## Changelog Generation + +### Why Changelog? + +A changelog is a file that contains a curated, chronologically ordered list of notable changes for each version of a project to make it easier for users and contributors. + +For more information, see the [Keep a Changelog](https://keepachangelog.com/) standard. + +### Why Generate Changelogs via Conventional Commits? + +It's hard to maintain changelogs by hand and most of the time it fails. Manual changelog updates lead to: + +- Discrepancy between docs and actual versions +- Missing breaking changes +- Outdated changelogs + +You can automate the process of generating changelogs via GitHub Actions or locally via git hooks, which speeds up the process significantly. + +## Bump the Version Precisely + +### Benefits of Bumping Version Through Conventional Commits + +- We shouldn't depend on one owner who knows every change between releases +- Every contributor should decide the impact of their changes during the development process +- You can auto-bump the version once it's merged to a specific branch (no more forgotten version updates) +- See [Conventional Commits Version Bump](https://github.com/marketplace/actions/conventional-commits-version-bump) GitHub Action +- Speeds up the process +- Prevents human mistakes + +**Major Version Bump** + +```text +refactor!: drop support for Node 6 +``` + +**Minor Version Bump** + +```text +feat(SHOPPER-000): introduce OrderMonitor v2 +``` + +**Patch Version Bump** + +```text +fix(SHOPPER-000): correct minor typos in code +``` + +## How About Squash Merges? + +Why do we need squash merges if we have good commit messages? + +### Pros + +- Every squash commit linked to PR +- Cleaner git history +- Easy roll-back + +### Cons + +- It changes commit history, which can cause conflicts +- It's hard to understand what actually changed +- Sometimes what you commit is not related to the purpose of the PR diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 58fc378..ba8ce54 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -13,6 +13,8 @@ on: jobs: check: + permissions: + contents: read runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -38,6 +40,6 @@ jobs: - run: pnpm lint working-directory: ui - - run: pnpm format --check + - run: pnpm format working-directory: ui diff --git a/.github/workflows/issue-checkbox-checker.yml b/.github/workflows/issue-checkbox-checker.yml new file mode 100644 index 0000000..ce7e011 --- /dev/null +++ b/.github/workflows/issue-checkbox-checker.yml @@ -0,0 +1,104 @@ +name: Issue Checkbox Checker + +on: + issues: + types: [opened, edited] + +permissions: + issues: write + +jobs: + check-checkboxes: + runs-on: ubuntu-latest + steps: + - name: Check for unchecked prerequisites + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + if (!issue) return; + + const body = issue.body || ''; + + // Check if "I have not read carefully" checkbox is checked + const notReadPatterns = [ + /- \[[xX]\] I have not read carefully/, + /- \[[xX]\] 我未仔细阅读/ + ]; + + const hasNotReadChecked = notReadPatterns.some(pattern => pattern.test(body)); + + if (hasNotReadChecked) { + const closeMessage = [ + '## Issue Automatically Closed / Issue 已自动关闭', + '', + '**English:**', + 'This issue has been automatically closed because you checked "I have not read carefully."', + '', + 'Please:', + '1. Read the [README](https://github.com/' + context.repo.owner + '/' + context.repo.repo + '/blob/main/README.md) and documentation carefully', + '2. Search for existing issues', + '3. Fill out the issue template completely', + '4. Submit a new issue when ready', + '', + '**中文:**', + '此 Issue 已被自动关闭,因为您勾选了"我未仔细阅读"。', + '', + '请:', + '1. 仔细阅读 [README](https://github.com/' + context.repo.owner + '/' + context.repo.repo + '/blob/main/README.md) 和文档', + '2. 搜索现有 Issue', + '3. 完整填写 Issue 模板', + '4. 准备好后提交新的 Issue', + '', + '---', + '*This is an automated action. If you believe this was done in error, please contact the maintainers.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: closeMessage + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned' + }); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ['invalid', 'auto-closed'] + }); + + return; + } + + // Count total checkboxes and checked boxes + const totalBoxes = (body.match(/- \[[ xX]\]/g) || []).length; + const checkedBoxes = (body.match(/- \[[xX]\]/g) || []).length; + + // If no boxes are checked in prerequisites, add a reminder + if (totalBoxes > 0 && checkedBoxes === 0) { + const reminderMessage = [ + '## Reminder / 提醒', + '', + '**English:**', + 'Please check the prerequisite boxes in the issue template to confirm you have completed the required steps.', + '', + '**中文:**', + '请勾选 Issue 模板中的前置条件复选框,以确认您已完成必要步骤。' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: reminderMessage + }); + } diff --git a/.github/workflows/prek.yml b/.github/workflows/prek.yml new file mode 100644 index 0000000..8e43763 --- /dev/null +++ b/.github/workflows/prek.yml @@ -0,0 +1,60 @@ +name: Prek Checks + +on: + push: + branches: ["main", "dev"] + workflow_dispatch: + +permissions: + contents: write + +jobs: + prek: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]')" + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install system dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update || true + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + build-essential \ + libssl-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + pkg-config + + - name: Run prek + id: prek + uses: j178/prek-action@v1 + continue-on-error: true + + - name: Check for changes + id: check_changes + if: steps.prek.outcome == 'failure' + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + fi + + - name: Commit fixes + if: steps.prek.outcome == 'failure' && steps.check_changes.outputs.has_changes == 'true' + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: apply prek auto-fixes [skip ci]" + commit_user_name: "hydroroll-bot" + commit_user_email: "bot@hydroroll.team" + skip_dirty_check: true diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..4005b2c --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,92 @@ +name: 'Close stale issues' + +on: + schedule: + - cron: '0 0 * * *' # Run daily at midnight UTC + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + # Issues + days-before-issue-stale: 90 + days-before-issue-close: 7 + stale-issue-label: 'stale' + exempt-issue-labels: 'pinned,enhancement,documentation' + stale-issue-message: | + ## This issue has been automatically marked as stale + ## 此 Issue 已被自动标记为过期 + + **English:** + This issue has had no activity for 90 days and will be closed in 7 days if no further activity occurs. + + If this issue is still relevant: + - Comment with an update + - Provide additional information + - Confirm you're still experiencing the problem + + **中文:** + 此 Issue 已 90 天无活动,如果继续无活动将在 7 天后关闭。 + + 如果此问题仍然相关: + - 发表评论更新状态 + - 提供额外信息 + - 确认您仍在遇到该问题 + + --- + *This is an automated message. To prevent closure, simply comment on this issue.* + close-issue-message: | + ## This issue has been automatically closed + ## 此 Issue 已被自动关闭 + + **English:** + This issue was automatically closed due to inactivity. If you're still experiencing this problem, please open a new issue with updated information. + + **中文:** + 此 Issue 因无活动而被自动关闭。如果您仍然遇到此问题,请开启一个新的 Issue 并提供最新信息。 + + # Pull Requests + days-before-pr-stale: 60 + days-before-pr-close: 14 + stale-pr-label: 'stale' + exempt-pr-labels: 'pinned,security' + stale-pr-message: | + ## This pull request has been automatically marked as stale + ## 此 PR 已被自动标记为过期 + + **English:** + This pull request has had no activity for 60 days and will be closed in 14 days if no further activity occurs. + + If you're still working on this: + - Push new commits + - Comment with a status update + - Request a review + + **中文:** + 此 PR 已 60 天无活动,如果继续无活动将在 14 天后关闭。 + + 如果您仍在处理此问题: + - 推送新的提交 + - 发表评论更新状态 + - 请求审查 + close-pr-message: | + ## This pull request has been automatically closed + ## 此 PR 已被自动关闭 + + **English:** + This pull request was automatically closed due to inactivity. Feel free to reopen if you resume work on this. + + **中文:** + 此 PR 因无活动而被自动关闭。如果您恢复工作,请随时重新开启。 + + # General settings + operations-per-run: 100 + remove-stale-when-updated: true + ascending: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8ca056e..8bf6d2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,9 @@ on: branches: ["main"] workflow_dispatch: +permissions: + contents: read + env: CARGO_TERM_COLOR: always @@ -42,8 +45,8 @@ jobs: - name: Install Dependencies (Ubuntu) if: runner.os == 'Linux' && !matrix.wayland run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev + sudo apt-get update || true + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libfuse2 - name: Install Dependencies (Arch Linux) if: matrix.wayland @@ -62,6 +65,27 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable + - name: Install Node.js + if: github.event_name == 'workflow_dispatch' + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + if: github.event_name == 'workflow_dispatch' + uses: pnpm/action-setup@v2 + with: + version: 9 + + - name: Install Frontend Dependencies + if: github.event_name == 'workflow_dispatch' + working-directory: ./ui + run: pnpm install + + - name: Install Tauri CLI + if: github.event_name == 'workflow_dispatch' + run: cargo install tauri-cli + - name: Rust Cache uses: swatinem/rust-cache@v2 with: @@ -71,6 +95,48 @@ jobs: working-directory: ./src-tauri run: cargo test --verbose - - name: Build (Dev) + - name: Build Rust Only (Push/PR) + if: github.event_name != 'workflow_dispatch' working-directory: ./src-tauri run: cargo build --verbose + + - name: Build App (Debug) + if: github.event_name == 'workflow_dispatch' + run: cargo tauri build --debug + + - name: Get Short SHA + if: github.event_name == 'workflow_dispatch' + id: slug + run: echo "sha8=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT + + - name: Upload Artifact (Linux) + if: runner.os == 'Linux' && github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: dropout-linux-${{ matrix.wayland && 'arch' || 'ubuntu' }}-${{ steps.slug.outputs.sha8 }} + path: | + src-tauri/target/debug/bundle/appimage/*.AppImage + src-tauri/target/debug/bundle/deb/*.deb + src-tauri/target/debug/dropout + retention-days: 5 + + - name: Upload Artifact (Windows) + if: runner.os == 'Windows' && github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: dropout-windows-${{ steps.slug.outputs.sha8 }} + path: | + src-tauri/target/debug/bundle/msi/*.msi + src-tauri/target/debug/bundle/nsis/*.exe + src-tauri/target/debug/dropout.exe + retention-days: 5 + + - name: Upload Artifact (macOS) + if: runner.os == 'macOS' && github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: dropout-macos-${{ steps.slug.outputs.sha8 }} + path: | + src-tauri/target/debug/bundle/dmg/*.dmg + src-tauri/target/debug/bundle/macos/DropOut.app + retention-days: 5 @@ -5,4 +5,23 @@ target/ Cargo.lock # Tauri dev files -src-tauri/gen/
\ No newline at end of file +src-tauri/gen/ + +# Python version +.python-version + +# Ruff +.ruff_cache/ + +# ESLint +.eslintcache + +# Svelte +.svelte-kit/ + +# Node.js +node_modules/ + +# Python Build +dist/ +__pycache__/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7e37cac --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +ci: + autofix_prs: true + autofix_commit_msg: "[pre-commit.ci] auto fixes from pre-commit hooks [skip ci]" + skip: [fmt,cargo-check,clippy] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-json + exclude: ^ui/tsconfig.*\.json$ + - id: check-toml + - id: check-yaml + - id: check-case-conflict + - id: fix-byte-order-marker + - id: end-of-file-fixer + - id: check-merge-conflict + - id: detect-private-key + - id: check-ast + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.13 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + + - repo: https://github.com/FeryET/pre-commit-rust + rev: v1.2.1 + hooks: + - id: fmt + args: [ --manifest-path src-tauri/Cargo.toml ] + files: ^src-tauri/.*\.rs$ + - id: cargo-check + args: [ --manifest-path src-tauri/Cargo.toml ] + files: ^src-tauri/.*\.rs$ + - id: clippy + args: [ --manifest-path src-tauri/Cargo.toml ] + files: ^src-tauri/.*\.rs$ @@ -1 +1 @@ -dropout.hydroroll.team
\ No newline at end of file +dropout.hydroroll.team @@ -1,4 +1,13 @@ -# DropOut +# Drop*O*ut + +[](https://app.fossa.com/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut?ref=badge_small) +[](https://github.com/pre-commit/pre-commit) +[](https://results.pre-commit.ci/latest/github/HsiangNianian/DropOut/main) +[](https://github.com/astral-sh/ruff) +[](https://github.com/HsiangNianian/DropOut/actions/workflows/codeql.yml) +[](https://github.com/HsiangNianian/DropOut/actions/workflows/dependabot/dependabot-updates) +[](https://github.com/HsiangNianian/DropOut/actions/workflows/release.yml) +[](https://github.com/HsiangNianian/DropOut/actions/workflows/test.yml) DropOut is a modern, minimalist, and efficient Minecraft launcher built with the latest web and system technologies. It leverages **Tauri v2** to deliver a lightweight application with a robust **Rust** backend and a reactive **Svelte 5** frontend. @@ -100,4 +109,9 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## License +[](https://app.fossa.com/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut?ref=badge_shield&issueType=license) +[](https://app.fossa.com/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut?ref=badge_shield&issueType=security) + +[](https://app.fossa.com/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut?ref=badge_large) + Distributed under the MIT License. See `LICENSE` for more information. diff --git a/_version.py b/_version.py new file mode 100644 index 0000000..9eb734d --- /dev/null +++ b/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.23" diff --git a/assets/image.png b/assets/image.png Binary files differindex db6ada3..5bd52e1 100644 --- a/assets/image.png +++ b/assets/image.png diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d49374f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "dropout" +dynamic = ["version"] +description = "DropOut is a modern, minimalist, and efficient Minecraft launcher built with the latest web and system technologies. It leverages Tauri v2 to deliver a lightweight application with a robust Rust backend and a reactive Svelte 5 frontend." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["."] +only-include = ["_version.py"] + +[dependency-groups] +dev = [ + "prek>=0.2.28", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 97529a1..ecd0beb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dropout" -version = "0.1.23" +version = "0.1.26" edition = "2021" authors = ["HsiangNianian"] description = "The DropOut Minecraft Game Launcher" @@ -27,8 +27,11 @@ flate2 = "1.0" tar = "0.4" dirs = "5.0" serde_urlencoded = "0.7.1" -tauri-plugin-dialog = "2.5.0" +tauri-plugin-dialog = "2.6.0" tauri-plugin-fs = "2.4.5" +bytes = "1.11.0" +chrono = "0.4" +regex = "1.12.2" [build-dependencies] tauri-build = { version = "2.0", features = [] } @@ -45,4 +48,3 @@ section = "games" assets = [ ["target/release/dropout", "usr/bin/", "755"], ] - diff --git a/src-tauri/icons/icon.svg b/src-tauri/icons/icon.svg index d8b0ed7..0baf00f 100644 --- a/src-tauri/icons/icon.svg +++ b/src-tauri/icons/icon.svg @@ -47,4 +47,4 @@ <!-- Layer 3: Output - x=412 --> <circle cx="412" cy="256" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/> -</svg>
\ No newline at end of file +</svg> diff --git a/src-tauri/src/core/account_storage.rs b/src-tauri/src/core/account_storage.rs index 569df7b..8998206 100644 --- a/src-tauri/src/core/account_storage.rs +++ b/src-tauri/src/core/account_storage.rs @@ -138,6 +138,7 @@ impl AccountStorage { } } + #[allow(dead_code)] pub fn set_active_account(&self, uuid: &str) -> Result<(), String> { let mut store = self.load(); if store.accounts.iter().any(|a| a.id() == uuid) { @@ -148,6 +149,7 @@ impl AccountStorage { } } + #[allow(dead_code)] pub fn get_all_accounts(&self) -> Vec<StoredAccount> { self.load().accounts } diff --git a/src-tauri/src/core/assistant.rs b/src-tauri/src/core/assistant.rs new file mode 100644 index 0000000..9a8f7bf --- /dev/null +++ b/src-tauri/src/core/assistant.rs @@ -0,0 +1,694 @@ +use super::config::AssistantConfig; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use tauri::{Emitter, Window}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub role: String, + pub content: String, +} + +#[derive(Debug, Serialize)] +pub struct OllamaChatRequest { + pub model: String, + pub messages: Vec<Message>, + pub stream: bool, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OllamaChatResponse { + pub model: String, + pub created_at: String, + pub message: Message, + pub done: bool, +} + +// Ollama model list response structures +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OllamaModelDetails { + pub format: Option<String>, + pub family: Option<String>, + pub parameter_size: Option<String>, + pub quantization_level: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OllamaModel { + pub name: String, + pub modified_at: Option<String>, + pub size: Option<u64>, + pub digest: Option<String>, + pub details: Option<OllamaModelDetails>, +} + +#[derive(Debug, Deserialize)] +pub struct OllamaTagsResponse { + pub models: Vec<OllamaModel>, +} + +// Simplified model info for frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + pub id: String, + pub name: String, + pub size: Option<String>, + pub details: Option<String>, +} + +#[derive(Debug, Serialize)] +pub struct OpenAIChatRequest { + pub model: String, + pub messages: Vec<Message>, + pub stream: bool, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIChoice { + pub index: i32, + pub message: Message, + pub finish_reason: Option<String>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIChatResponse { + pub id: String, + pub object: String, + pub created: i64, + pub model: String, + pub choices: Vec<OpenAIChoice>, +} + +// OpenAI models list response +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIModelData { + pub id: String, + pub object: String, + pub created: Option<i64>, + pub owned_by: Option<String>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIModelsResponse { + pub object: String, + pub data: Vec<OpenAIModelData>, +} + +// Streaming response structures +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerationStats { + pub total_duration: u64, + pub load_duration: u64, + pub prompt_eval_count: u64, + pub prompt_eval_duration: u64, + pub eval_count: u64, + pub eval_duration: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamChunk { + pub content: String, + pub done: bool, + pub stats: Option<GenerationStats>, +} + +// Ollama streaming response (each line is a JSON object) +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OllamaStreamResponse { + pub model: Option<String>, + pub created_at: Option<String>, + pub message: Option<Message>, + pub done: bool, + pub total_duration: Option<u64>, + pub load_duration: Option<u64>, + pub prompt_eval_count: Option<u64>, + pub prompt_eval_duration: Option<u64>, + pub eval_count: Option<u64>, + pub eval_duration: Option<u64>, +} + +// OpenAI streaming response +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIStreamDelta { + pub role: Option<String>, + pub content: Option<String>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIStreamChoice { + pub index: i32, + pub delta: OpenAIStreamDelta, + pub finish_reason: Option<String>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIStreamResponse { + pub id: Option<String>, + pub object: Option<String>, + pub created: Option<i64>, + pub model: Option<String>, + pub choices: Vec<OpenAIStreamChoice>, +} + +#[derive(Clone)] +pub struct GameAssistant { + client: reqwest::Client, + pub log_buffer: VecDeque<String>, + pub max_log_lines: usize, +} + +impl GameAssistant { + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + log_buffer: VecDeque::new(), + max_log_lines: 100, + } + } + + pub fn add_log(&mut self, line: String) { + if self.log_buffer.len() >= self.max_log_lines { + self.log_buffer.pop_front(); + } + self.log_buffer.push_back(line); + } + + pub fn get_log_context(&self) -> String { + self.log_buffer + .iter() + .cloned() + .collect::<Vec<_>>() + .join("\n") + } + + pub async fn check_health(&self, config: &AssistantConfig) -> bool { + if config.llm_provider == "ollama" { + match self + .client + .get(format!("{}/api/tags", config.ollama_endpoint)) + .send() + .await + { + Ok(res) => res.status().is_success(), + Err(_) => false, + } + } else if config.llm_provider == "openai" { + // For OpenAI, just check if API key is set + config.openai_api_key.is_some() && !config.openai_api_key.as_ref().unwrap().is_empty() + } else { + false + } + } + + pub async fn chat( + &self, + mut messages: Vec<Message>, + config: &AssistantConfig, + ) -> Result<Message, String> { + // Inject system prompt and log context + if !messages.iter().any(|m| m.role == "system") { + let context = self.get_log_context(); + let mut system_content = config.system_prompt.clone(); + + // Add language instruction if not auto + if config.response_language != "auto" { + system_content = format!("{}\n\nIMPORTANT: Respond in {}. Do not include Pinyin or English translations unless explicitly requested.", system_content, config.response_language); + } + + // Add log context if available + if !context.is_empty() { + system_content = format!( + "{}\n\nRecent game logs:\n```\n{}\n```", + system_content, context + ); + } + + messages.insert( + 0, + Message { + role: "system".to_string(), + content: system_content, + }, + ); + } + + if config.llm_provider == "ollama" { + self.chat_ollama(messages, config).await + } else if config.llm_provider == "openai" { + self.chat_openai(messages, config).await + } else { + Err(format!("Unknown LLM provider: {}", config.llm_provider)) + } + } + + async fn chat_ollama( + &self, + messages: Vec<Message>, + config: &AssistantConfig, + ) -> Result<Message, String> { + let request = OllamaChatRequest { + model: config.ollama_model.clone(), + messages, + stream: false, + }; + + let response = self + .client + .post(format!("{}/api/chat", config.ollama_endpoint)) + .json(&request) + .send() + .await + .map_err(|e| format!("Ollama request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Ollama API returned error: {}", response.status())); + } + + let chat_response: OllamaChatResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse Ollama response: {}", e))?; + + Ok(chat_response.message) + } + + async fn chat_openai( + &self, + messages: Vec<Message>, + config: &AssistantConfig, + ) -> Result<Message, String> { + let api_key = config + .openai_api_key + .as_ref() + .ok_or("OpenAI API key not configured")?; + + let request = OpenAIChatRequest { + model: config.openai_model.clone(), + messages, + stream: false, + }; + + let response = self + .client + .post(format!("{}/chat/completions", config.openai_endpoint)) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("OpenAI request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("OpenAI API error ({}): {}", status, error_text)); + } + + let chat_response: OpenAIChatResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse OpenAI response: {}", e))?; + + chat_response + .choices + .into_iter() + .next() + .map(|c| c.message) + .ok_or_else(|| "No response from OpenAI".to_string()) + } + + pub async fn list_ollama_models(&self, endpoint: &str) -> Result<Vec<ModelInfo>, String> { + let response = self + .client + .get(format!("{}/api/tags", endpoint)) + .send() + .await + .map_err(|e| format!("Failed to connect to Ollama: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Ollama API error: {}", response.status())); + } + + let tags_response: OllamaTagsResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse Ollama response: {}", e))?; + + let models: Vec<ModelInfo> = tags_response + .models + .into_iter() + .map(|m| { + let size_str = m.size.map(format_size); + let details_str = m.details.map(|d| { + let mut parts = Vec::new(); + if let Some(family) = d.family { + parts.push(family); + } + if let Some(params) = d.parameter_size { + parts.push(params); + } + if let Some(quant) = d.quantization_level { + parts.push(quant); + } + parts.join(" / ") + }); + + ModelInfo { + id: m.name.clone(), + name: m.name, + size: size_str, + details: details_str, + } + }) + .collect(); + + Ok(models) + } + + pub async fn list_openai_models( + &self, + config: &AssistantConfig, + ) -> Result<Vec<ModelInfo>, String> { + let api_key = config + .openai_api_key + .as_ref() + .ok_or("OpenAI API key not configured")?; + + let response = self + .client + .get(format!("{}/models", config.openai_endpoint)) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await + .map_err(|e| format!("Failed to connect to OpenAI: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("OpenAI API error ({}): {}", status, error_text)); + } + + let models_response: OpenAIModelsResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse OpenAI response: {}", e))?; + + // Filter to only show chat models (gpt-*) + let models: Vec<ModelInfo> = models_response + .data + .into_iter() + .filter(|m| { + m.id.starts_with("gpt-") || m.id.starts_with("o1") || m.id.contains("turbo") + }) + .map(|m| ModelInfo { + id: m.id.clone(), + name: m.id, + size: None, + details: m.owned_by, + }) + .collect(); + + Ok(models) + } + + // Streaming chat methods + pub async fn chat_stream( + &self, + mut messages: Vec<Message>, + config: &AssistantConfig, + window: &Window, + ) -> Result<String, String> { + // Inject system prompt and log context + if !messages.iter().any(|m| m.role == "system") { + let context = self.get_log_context(); + let mut system_content = config.system_prompt.clone(); + + if config.response_language != "auto" { + system_content = format!("{}\n\nIMPORTANT: Respond in {}. Do not include Pinyin or English translations unless explicitly requested.", system_content, config.response_language); + } + + if !context.is_empty() { + system_content = format!( + "{}\n\nRecent game logs:\n```\n{}\n```", + system_content, context + ); + } + + messages.insert( + 0, + Message { + role: "system".to_string(), + content: system_content, + }, + ); + } + + if config.llm_provider == "ollama" { + self.chat_stream_ollama(messages, config, window).await + } else if config.llm_provider == "openai" { + self.chat_stream_openai(messages, config, window).await + } else { + Err(format!("Unknown LLM provider: {}", config.llm_provider)) + } + } + + async fn chat_stream_ollama( + &self, + messages: Vec<Message>, + config: &AssistantConfig, + window: &Window, + ) -> Result<String, String> { + let request = OllamaChatRequest { + model: config.ollama_model.clone(), + messages, + stream: true, + }; + + let response = self + .client + .post(format!("{}/api/chat", config.ollama_endpoint)) + .json(&request) + .send() + .await + .map_err(|e| format!("Ollama request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Ollama API returned error: {}", response.status())); + } + + let mut full_content = String::new(); + let mut stream = response.bytes_stream(); + + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + let text = String::from_utf8_lossy(&chunk); + // Ollama returns newline-delimited JSON + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + if let Ok(stream_response) = + serde_json::from_str::<OllamaStreamResponse>(line) + { + if let Some(msg) = stream_response.message { + full_content.push_str(&msg.content); + let _ = window.emit( + "assistant-stream", + StreamChunk { + content: msg.content, + done: stream_response.done, + stats: None, + }, + ); + } + if stream_response.done { + let stats = if let ( + Some(total), + Some(load), + Some(prompt_cnt), + Some(prompt_dur), + Some(eval_cnt), + Some(eval_dur), + ) = ( + stream_response.total_duration, + stream_response.load_duration, + stream_response.prompt_eval_count, + stream_response.prompt_eval_duration, + stream_response.eval_count, + stream_response.eval_duration, + ) { + Some(GenerationStats { + total_duration: total, + load_duration: load, + prompt_eval_count: prompt_cnt, + prompt_eval_duration: prompt_dur, + eval_count: eval_cnt, + eval_duration: eval_dur, + }) + } else { + None + }; + + let _ = window.emit( + "assistant-stream", + StreamChunk { + content: String::new(), + done: true, + stats, + }, + ); + } + } + } + } + Err(e) => { + return Err(format!("Stream error: {}", e)); + } + } + } + + Ok(full_content) + } + + async fn chat_stream_openai( + &self, + messages: Vec<Message>, + config: &AssistantConfig, + window: &Window, + ) -> Result<String, String> { + let api_key = config + .openai_api_key + .as_ref() + .ok_or("OpenAI API key not configured")?; + + let request = OpenAIChatRequest { + model: config.openai_model.clone(), + messages, + stream: true, + }; + + let response = self + .client + .post(format!("{}/chat/completions", config.openai_endpoint)) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("OpenAI request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("OpenAI API error ({}): {}", status, error_text)); + } + + let mut full_content = String::new(); + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + // Process complete lines + while let Some(pos) = buffer.find('\n') { + let line = buffer[..pos].to_string(); + buffer = buffer[pos + 1..].to_string(); + + let line = line.trim(); + if line.is_empty() || line == "data: [DONE]" { + if line == "data: [DONE]" { + let _ = window.emit( + "assistant-stream", + StreamChunk { + content: String::new(), + done: true, + stats: None, + }, + ); + } + continue; + } + + if let Some(data) = line.strip_prefix("data: ") { + if let Ok(stream_response) = + serde_json::from_str::<OpenAIStreamResponse>(data) + { + if let Some(choice) = stream_response.choices.first() { + if let Some(content) = &choice.delta.content { + full_content.push_str(content); + let _ = window.emit( + "assistant-stream", + StreamChunk { + content: content.clone(), + done: false, + stats: None, + }, + ); + } + if choice.finish_reason.is_some() { + let _ = window.emit( + "assistant-stream", + StreamChunk { + content: String::new(), + done: true, + stats: None, + }, + ); + } + } + } + } + } + } + Err(e) => { + return Err(format!("Stream error: {}", e)); + } + } + } + + Ok(full_content) + } +} + +fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.1} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} + +pub struct AssistantState { + pub assistant: Arc<Mutex<GameAssistant>>, +} + +impl AssistantState { + pub fn new() -> Self { + Self { + assistant: Arc::new(Mutex::new(GameAssistant::new())), + } + } +} diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs index 5f01a58..ac5904c 100644 --- a/src-tauri/src/core/auth.rs +++ b/src-tauri/src/core/auth.rs @@ -136,6 +136,7 @@ pub async fn refresh_microsoft_token(refresh_token: &str) -> Result<TokenRespons } /// Check if a Microsoft account token is expired or about to expire +#[allow(dead_code)] pub fn is_token_expired(expires_at: i64) -> bool { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -430,17 +431,21 @@ pub async fn fetch_profile(mc_access_token: &str) -> Result<MinecraftProfile, St // 7. Check Game Ownership #[derive(Debug, Serialize, Deserialize)] +#[allow(dead_code)] pub struct Entitlement { pub name: String, } #[derive(Debug, Serialize, Deserialize)] +#[allow(dead_code)] pub struct EntitlementsResponse { pub items: Vec<Entitlement>, pub signature: Option<String>, - pub keyId: Option<String>, + #[serde(rename = "keyId")] + pub key_id: Option<String>, } +#[allow(dead_code)] pub async fn check_ownership(mc_access_token: &str) -> Result<bool, String> { let client = get_client(); let url = "https://api.minecraftservices.com/entitlements/mcstore"; diff --git a/src-tauri/src/core/config.rs b/src-tauri/src/core/config.rs index 43c8145..4c4acad 100644 --- a/src-tauri/src/core/config.rs +++ b/src-tauri/src/core/config.rs @@ -6,6 +6,44 @@ use tauri::{AppHandle, Manager}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] +pub struct AssistantConfig { + pub enabled: bool, + pub llm_provider: String, // "ollama" or "openai" + // Ollama settings + pub ollama_endpoint: String, + pub ollama_model: String, + // OpenAI settings + pub openai_api_key: Option<String>, + pub openai_endpoint: String, + pub openai_model: String, + // Common settings + pub system_prompt: String, + pub response_language: String, + // TTS settings + pub tts_enabled: bool, + pub tts_provider: String, // "disabled", "piper", "edge" +} + +impl Default for AssistantConfig { + fn default() -> Self { + Self { + enabled: true, + llm_provider: "ollama".to_string(), + ollama_endpoint: "http://localhost:11434".to_string(), + ollama_model: "llama3".to_string(), + openai_api_key: None, + openai_endpoint: "https://api.openai.com/v1".to_string(), + openai_model: "gpt-3.5-turbo".to_string(), + system_prompt: "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.".to_string(), + response_language: "auto".to_string(), + tts_enabled: false, + tts_provider: "disabled".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct LauncherConfig { pub min_memory: u32, // in MB pub max_memory: u32, // in MB @@ -20,6 +58,7 @@ pub struct LauncherConfig { pub theme: String, pub log_upload_service: String, // "paste.rs" or "pastebin.com" pub pastebin_api_key: Option<String>, + pub assistant: AssistantConfig, } impl Default for LauncherConfig { @@ -38,6 +77,7 @@ impl Default for LauncherConfig { theme: "dark".to_string(), log_upload_service: "paste.rs".to_string(), pastebin_api_key: None, + assistant: AssistantConfig::default(), } } } diff --git a/src-tauri/src/core/downloader.rs b/src-tauri/src/core/downloader.rs index bf6334f..9c6b7f0 100644 --- a/src-tauri/src/core/downloader.rs +++ b/src-tauri/src/core/downloader.rs @@ -111,9 +111,8 @@ impl DownloadQueue { /// Remove a completed or cancelled download pub fn remove(&mut self, major_version: u32, image_type: &str) { - self.pending_downloads.retain(|d| { - !(d.major_version == major_version && d.image_type == image_type) - }); + self.pending_downloads + .retain(|d| !(d.major_version == major_version && d.image_type == image_type)); } } @@ -174,7 +173,8 @@ pub async fn download_with_resume( let content = tokio::fs::read_to_string(&meta_path) .await .map_err(|e| e.to_string())?; - serde_json::from_str(&content).unwrap_or_else(|_| create_new_metadata(url, &file_name, total_size, checksum)) + serde_json::from_str(&content) + .unwrap_or_else(|_| create_new_metadata(url, &file_name, total_size, checksum)) } else { create_new_metadata(url, &file_name, total_size, checksum) }; @@ -191,6 +191,7 @@ pub async fn download_with_resume( .create(true) .write(true) .read(true) + .truncate(false) .open(&part_path) .await .map_err(|e| format!("Failed to open part file: {}", e))?; @@ -220,9 +221,7 @@ pub async fn download_with_resume( let segment_end = segment.end; let app_handle = app_handle.clone(); let file_name = file_name.clone(); - let total_size = total_size; let last_progress_bytes = last_progress_bytes.clone(); - let start_time = start_time.clone(); let handle = tokio::spawn(async move { let _permit = semaphore.acquire().await.unwrap(); @@ -240,7 +239,9 @@ pub async fn download_with_resume( .await .map_err(|e| format!("Request failed: {}", e))?; - if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT { + if !response.status().is_success() + && response.status() != reqwest::StatusCode::PARTIAL_CONTENT + { return Err(format!("Server returned error: {}", response.status())); } @@ -319,7 +320,8 @@ pub async fn download_with_resume( if e.contains("cancelled") { // Save progress for resume metadata.downloaded_bytes = progress.load(Ordering::Relaxed); - let meta_content = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?; + let meta_content = + serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?; tokio::fs::write(&meta_path, meta_content).await.ok(); return Err(e); } @@ -357,7 +359,7 @@ pub async fn download_with_resume( let data = tokio::fs::read(&part_path) .await .map_err(|e| format!("Failed to read file for verification: {}", e))?; - + if !verify_checksum(&data, Some(expected), None) { // Checksum failed, delete files and retry tokio::fs::remove_file(&part_path).await.ok(); @@ -378,7 +380,12 @@ pub async fn download_with_resume( } /// Create new download metadata with segments -fn create_new_metadata(url: &str, file_name: &str, total_size: u64, checksum: Option<&str>) -> DownloadMetadata { +fn create_new_metadata( + url: &str, + file_name: &str, + total_size: u64, + checksum: Option<&str>, +) -> DownloadMetadata { let segment_count = get_segment_count(total_size); let segment_size = total_size / segment_count as u64; let mut segments = Vec::new(); @@ -559,11 +566,7 @@ pub async fn download_files( if task.sha256.is_some() || task.sha1.is_some() { if let Ok(data) = tokio::fs::read(&task.path).await { - if verify_checksum( - &data, - task.sha256.as_deref(), - task.sha1.as_deref(), - ) { + if verify_checksum(&data, task.sha256.as_deref(), task.sha1.as_deref()) { // Already valid, skip download let skipped_size = tokio::fs::metadata(&task.path) .await diff --git a/src-tauri/src/core/fabric.rs b/src-tauri/src/core/fabric.rs index 3e4d50d..32790c7 100644 --- a/src-tauri/src/core/fabric.rs +++ b/src-tauri/src/core/fabric.rs @@ -67,13 +67,11 @@ pub struct FabricLibrary { #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(untagged)] pub enum FabricMainClass { - Structured { - client: String, - server: String, - }, + Structured { client: String, server: String }, Simple(String), } +#[allow(dead_code)] impl FabricMainClass { pub fn client(&self) -> &str { match self { @@ -81,7 +79,7 @@ impl FabricMainClass { FabricMainClass::Simple(s) => s, } } - + pub fn server(&self) -> &str { match self { FabricMainClass::Structured { server, .. } => server, @@ -200,7 +198,7 @@ pub fn generate_version_id(game_version: &str, loader_version: &str) -> String { /// # Returns /// Information about the installed version. pub async fn install_fabric( - game_dir: &PathBuf, + game_dir: &std::path::Path, game_version: &str, loader_version: &str, ) -> Result<InstalledFabricVersion, Box<dyn Error + Send + Sync>> { @@ -240,7 +238,11 @@ pub async fn install_fabric( /// /// # Returns /// `true` if the version JSON exists, `false` otherwise. -pub fn is_fabric_installed(game_dir: &PathBuf, game_version: &str, loader_version: &str) -> bool { +pub fn is_fabric_installed( + game_dir: &std::path::Path, + game_version: &str, + loader_version: &str, +) -> bool { let version_id = generate_version_id(game_version, loader_version); let json_path = game_dir .join("versions") @@ -257,7 +259,7 @@ pub fn is_fabric_installed(game_dir: &PathBuf, game_version: &str, loader_versio /// # Returns /// A list of installed Fabric version IDs. pub async fn list_installed_fabric_versions( - game_dir: &PathBuf, + game_dir: &std::path::Path, ) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> { let versions_dir = game_dir.join("versions"); let mut installed = Vec::new(); diff --git a/src-tauri/src/core/forge.rs b/src-tauri/src/core/forge.rs index e69b296..65bf413 100644 --- a/src-tauri/src/core/forge.rs +++ b/src-tauri/src/core/forge.rs @@ -9,11 +9,14 @@ use serde::{Deserialize, Serialize}; use std::error::Error; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; use std::path::PathBuf; const FORGE_PROMOTIONS_URL: &str = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json"; const FORGE_MAVEN_URL: &str = "https://maven.minecraftforge.net/"; +const FORGE_FILES_URL: &str = "https://files.minecraftforge.net/"; /// Represents a Forge version entry. #[derive(Debug, Deserialize, Serialize, Clone)] @@ -43,6 +46,7 @@ pub struct InstalledForgeVersion { /// Forge installer manifest structure (from version.json inside installer JAR) #[derive(Debug, Deserialize)] +#[allow(dead_code)] struct ForgeInstallerManifest { id: Option<String>, #[serde(rename = "inheritsFrom")] @@ -177,36 +181,102 @@ pub fn generate_version_id(game_version: &str, forge_version: &str) -> String { format!("{}-forge-{}", game_version, forge_version) } +/// Try to download the Forge installer from multiple possible URL formats. +/// This is necessary because older Forge versions use different URL patterns. +async fn try_download_forge_installer( + game_version: &str, + forge_version: &str, +) -> Result<bytes::Bytes, Box<dyn Error + Send + Sync>> { + let forge_full = format!("{}-{}", game_version, forge_version); + // For older versions (like 1.7.10), the URL needs an additional -{game_version} suffix + let forge_full_with_suffix = format!("{}-{}", forge_full, game_version); + + // Try different URL formats for different Forge versions + // Order matters: try most common formats first, then fallback to alternatives + let url_patterns = vec![ + // Standard Maven format (for modern versions): forge/{game_version}-{forge_version}/forge-{game_version}-{forge_version}-installer.jar + format!( + "{}net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_MAVEN_URL, forge_full, forge_full + ), + // Old version format with suffix (for versions like 1.7.10): forge/{game_version}-{forge_version}-{game_version}/forge-{game_version}-{forge_version}-{game_version}-installer.jar + // This is the correct format for 1.7.10 and similar old versions + format!( + "{}net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_MAVEN_URL, forge_full_with_suffix, forge_full_with_suffix + ), + // Files.minecraftforge.net format with suffix (for old versions like 1.7.10) + format!( + "{}maven/net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_FILES_URL, forge_full_with_suffix, forge_full_with_suffix + ), + // Files.minecraftforge.net standard format (for older versions) + format!( + "{}maven/net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_FILES_URL, forge_full, forge_full + ), + // Alternative Maven format + format!( + "{}net/minecraftforge/forge/{}-{}/forge-{}-{}-installer.jar", + FORGE_MAVEN_URL, game_version, forge_version, game_version, forge_version + ), + // Alternative files format + format!( + "{}maven/net/minecraftforge/forge/{}-{}/forge-{}-{}-installer.jar", + FORGE_FILES_URL, game_version, forge_version, game_version, forge_version + ), + ]; + + let mut last_error = None; + for url in url_patterns { + println!("Trying Forge installer URL: {}", url); + match reqwest::get(&url).await { + Ok(response) => { + if response.status().is_success() { + match response.bytes().await { + Ok(bytes) => { + println!("Successfully downloaded Forge installer from: {}", url); + return Ok(bytes); + } + Err(e) => { + last_error = Some(format!("Failed to read response body: {}", e)); + continue; + } + } + } else { + last_error = Some(format!("HTTP {}: {}", response.status(), url)); + continue; + } + } + Err(e) => { + last_error = Some(format!("Request failed: {}", e)); + continue; + } + } + } + + Err(format!( + "Failed to download Forge installer from any URL. Last error: {}", + last_error.unwrap_or_else(|| "Unknown error".to_string()) + ) + .into()) +} + /// Fetch the Forge installer manifest to get the library list async fn fetch_forge_installer_manifest( game_version: &str, forge_version: &str, ) -> Result<ForgeInstallerManifest, Box<dyn Error + Send + Sync>> { - let forge_full = format!("{}-{}", game_version, forge_version); - - // Download the installer JAR to extract version.json - let installer_url = format!( - "{}net/minecraftforge/forge/{}/forge-{}-installer.jar", - FORGE_MAVEN_URL, forge_full, forge_full - ); - - println!("Fetching Forge installer from: {}", installer_url); - - let response = reqwest::get(&installer_url).await?; - if !response.status().is_success() { - return Err(format!("Failed to download Forge installer: {}", response.status()).into()); - } - - let bytes = response.bytes().await?; - + let bytes = try_download_forge_installer(game_version, forge_version).await?; + // Extract version.json from the JAR (which is a ZIP file) let cursor = std::io::Cursor::new(bytes.as_ref()); let mut archive = zip::ZipArchive::new(cursor)?; - + // Look for version.json in the archive let version_json = archive.by_name("version.json")?; let manifest: ForgeInstallerManifest = serde_json::from_reader(version_json)?; - + Ok(manifest) } @@ -224,7 +294,7 @@ async fn fetch_forge_installer_manifest( /// # Returns /// Information about the installed version. pub async fn install_forge( - game_dir: &PathBuf, + game_dir: &std::path::Path, game_version: &str, forge_version: &str, ) -> Result<InstalledForgeVersion, Box<dyn Error + Send + Sync>> { @@ -234,7 +304,8 @@ pub async fn install_forge( let manifest = fetch_forge_installer_manifest(game_version, forge_version).await?; // Create version JSON from the manifest - let version_json = create_forge_version_json_from_manifest(game_version, forge_version, &manifest)?; + let version_json = + create_forge_version_json_from_manifest(game_version, forge_version, &manifest)?; // Create the version directory let version_dir = game_dir.join("versions").join(&version_id); @@ -270,47 +341,38 @@ pub async fn run_forge_installer( forge_version: &str, java_path: &PathBuf, ) -> Result<(), Box<dyn Error + Send + Sync>> { - // Download the installer JAR - let installer_url = format!( - "{}net/minecraftforge/forge/{}-{}/forge-{}-{}-installer.jar", - FORGE_MAVEN_URL, game_version, forge_version, game_version, forge_version - ); - let installer_path = game_dir.join("forge-installer.jar"); - - // Download installer - let client = reqwest::Client::new(); - let response = client.get(&installer_url).send().await?; - - if !response.status().is_success() { - return Err(format!("Failed to download Forge installer: {}", response.status()).into()); - } - - let bytes = response.bytes().await?; + + // Download installer using the same multi-URL approach + let bytes = try_download_forge_installer(game_version, forge_version).await?; tokio::fs::write(&installer_path, &bytes).await?; - + // Run the installer in headless mode // The installer accepts --installClient <path> to install to a specific directory - let output = tokio::process::Command::new(java_path) - .arg("-jar") + let mut cmd = tokio::process::Command::new(java_path); + cmd.arg("-jar") .arg(&installer_path) .arg("--installClient") - .arg(game_dir) - .output() - .await?; - + .arg(game_dir); + + #[cfg(target_os = "windows")] + cmd.creation_flags(0x08000000); + + let output = cmd.output().await?; + // Clean up installer let _ = tokio::fs::remove_file(&installer_path).await; - + if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); return Err(format!( "Forge installer failed:\nstdout: {}\nstderr: {}", stdout, stderr - ).into()); + ) + .into()); } - + Ok(()) } @@ -332,13 +394,14 @@ fn create_forge_version_json_from_manifest( }); // Convert libraries to JSON format, preserving download info - let lib_entries: Vec<serde_json::Value> = manifest.libraries + let lib_entries: Vec<serde_json::Value> = manifest + .libraries .iter() .map(|lib| { let mut entry = serde_json::json!({ "name": lib.name }); - + // Add URL if present if let Some(url) = &lib.url { entry["url"] = serde_json::Value::String(url.clone()); @@ -346,19 +409,22 @@ fn create_forge_version_json_from_manifest( // Default to Forge Maven for Forge libraries entry["url"] = serde_json::Value::String(FORGE_MAVEN_URL.to_string()); } - + // Add downloads if present if let Some(downloads) = &lib.downloads { if let Some(artifact) = &downloads.artifact { let mut artifact_json = serde_json::Map::new(); if let Some(path) = &artifact.path { - artifact_json.insert("path".to_string(), serde_json::Value::String(path.clone())); + artifact_json + .insert("path".to_string(), serde_json::Value::String(path.clone())); } if let Some(url) = &artifact.url { - artifact_json.insert("url".to_string(), serde_json::Value::String(url.clone())); + artifact_json + .insert("url".to_string(), serde_json::Value::String(url.clone())); } if let Some(sha1) = &artifact.sha1 { - artifact_json.insert("sha1".to_string(), serde_json::Value::String(sha1.clone())); + artifact_json + .insert("sha1".to_string(), serde_json::Value::String(sha1.clone())); } if !artifact_json.is_empty() { entry["downloads"] = serde_json::json!({ @@ -367,7 +433,7 @@ fn create_forge_version_json_from_manifest( } } } - + entry }) .collect(); @@ -377,7 +443,7 @@ fn create_forge_version_json_from_manifest( "game": [], "jvm": [] }); - + if let Some(args) = &manifest.arguments { if let Some(game_args) = &args.game { arguments["game"] = serde_json::Value::Array(game_args.clone()); @@ -461,7 +527,12 @@ fn is_modern_forge(game_version: &str) -> bool { /// /// # Returns /// `true` if the version JSON exists, `false` otherwise. -pub fn is_forge_installed(game_dir: &PathBuf, game_version: &str, forge_version: &str) -> bool { +#[allow(dead_code)] +pub fn is_forge_installed( + game_dir: &std::path::Path, + game_version: &str, + forge_version: &str, +) -> bool { let version_id = generate_version_id(game_version, forge_version); let json_path = game_dir .join("versions") @@ -477,8 +548,9 @@ pub fn is_forge_installed(game_dir: &PathBuf, game_version: &str, forge_version: /// /// # Returns /// A list of installed Forge version IDs. +#[allow(dead_code)] pub async fn list_installed_forge_versions( - game_dir: &PathBuf, + game_dir: &std::path::Path, ) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> { let versions_dir = game_dir.join("versions"); let mut installed = Vec::new(); diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs new file mode 100644 index 0000000..90ec34e --- /dev/null +++ b/src-tauri/src/core/instance.rs @@ -0,0 +1,325 @@ +//! Instance/Profile management module. +//! +//! This module provides functionality to: +//! - Create and manage multiple isolated game instances +//! - Each instance has its own versions, libraries, assets, mods, and saves +//! - Support for instance switching and isolation + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; + +/// Represents a game instance/profile +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Instance { + pub id: String, // 唯一标识符(UUID) + pub name: String, // 显示名称 + pub game_dir: PathBuf, // 游戏目录路径 + pub version_id: Option<String>, // 当前选择的版本ID + pub created_at: i64, // 创建时间戳 + pub last_played: Option<i64>, // 最后游玩时间 + pub icon_path: Option<String>, // 图标路径(可选) + pub notes: Option<String>, // 备注(可选) + pub mod_loader: Option<String>, // 模组加载器类型:"fabric", "forge", "vanilla" + pub mod_loader_version: Option<String>, // 模组加载器版本 +} + +/// Configuration for all instances +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct InstanceConfig { + pub instances: Vec<Instance>, + pub active_instance_id: Option<String>, // 当前活动的实例ID +} + +/// State management for instances +pub struct InstanceState { + pub instances: Mutex<InstanceConfig>, + pub file_path: PathBuf, +} + +impl InstanceState { + /// Create a new InstanceState + pub fn new(app_handle: &AppHandle) -> Self { + let app_dir = app_handle.path().app_data_dir().unwrap(); + let file_path = app_dir.join("instances.json"); + + let config = if file_path.exists() { + let content = fs::read_to_string(&file_path).unwrap_or_default(); + serde_json::from_str(&content).unwrap_or_else(|_| InstanceConfig::default()) + } else { + InstanceConfig::default() + }; + + Self { + instances: Mutex::new(config), + file_path, + } + } + + /// Save the instance configuration to disk + pub fn save(&self) -> Result<(), String> { + let config = self.instances.lock().unwrap(); + let content = serde_json::to_string_pretty(&*config).map_err(|e| e.to_string())?; + fs::create_dir_all(self.file_path.parent().unwrap()).map_err(|e| e.to_string())?; + fs::write(&self.file_path, content).map_err(|e| e.to_string())?; + Ok(()) + } + + /// Create a new instance + pub fn create_instance( + &self, + name: String, + app_handle: &AppHandle, + ) -> Result<Instance, String> { + let app_dir = app_handle.path().app_data_dir().unwrap(); + let instance_id = uuid::Uuid::new_v4().to_string(); + let instance_dir = app_dir.join("instances").join(&instance_id); + let game_dir = instance_dir.clone(); + + // Create instance directory structure + fs::create_dir_all(&instance_dir).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("versions")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("libraries")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("assets")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("mods")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("config")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("saves")).map_err(|e| e.to_string())?; + + let instance = Instance { + id: instance_id.clone(), + name, + game_dir, + version_id: None, + created_at: chrono::Utc::now().timestamp(), + last_played: None, + icon_path: None, + notes: None, + mod_loader: Some("vanilla".to_string()), + mod_loader_version: None, + }; + + let mut config = self.instances.lock().unwrap(); + config.instances.push(instance.clone()); + + // If this is the first instance, set it as active + if config.active_instance_id.is_none() { + config.active_instance_id = Some(instance_id); + } + + drop(config); + self.save()?; + + Ok(instance) + } + + /// Delete an instance + pub fn delete_instance(&self, id: &str) -> Result<(), String> { + let mut config = self.instances.lock().unwrap(); + + // Find the instance + let instance_index = config + .instances + .iter() + .position(|i| i.id == id) + .ok_or_else(|| format!("Instance {} not found", id))?; + + let instance = config.instances[instance_index].clone(); + + // Remove from list + config.instances.remove(instance_index); + + // If this was the active instance, clear or set another as active + if config.active_instance_id.as_ref() == Some(&id.to_string()) { + config.active_instance_id = config.instances.first().map(|i| i.id.clone()); + } + + drop(config); + self.save()?; + + // Delete the instance directory + if instance.game_dir.exists() { + fs::remove_dir_all(&instance.game_dir) + .map_err(|e| format!("Failed to delete instance directory: {}", e))?; + } + + Ok(()) + } + + /// Update an instance + pub fn update_instance(&self, instance: Instance) -> Result<(), String> { + let mut config = self.instances.lock().unwrap(); + + let index = config + .instances + .iter() + .position(|i| i.id == instance.id) + .ok_or_else(|| format!("Instance {} not found", instance.id))?; + + config.instances[index] = instance; + drop(config); + self.save()?; + + Ok(()) + } + + /// Get an instance by ID + pub fn get_instance(&self, id: &str) -> Option<Instance> { + let config = self.instances.lock().unwrap(); + config.instances.iter().find(|i| i.id == id).cloned() + } + + /// List all instances + pub fn list_instances(&self) -> Vec<Instance> { + let config = self.instances.lock().unwrap(); + config.instances.clone() + } + + /// Set the active instance + pub fn set_active_instance(&self, id: &str) -> Result<(), String> { + let mut config = self.instances.lock().unwrap(); + + // Verify the instance exists + if !config.instances.iter().any(|i| i.id == id) { + return Err(format!("Instance {} not found", id)); + } + + config.active_instance_id = Some(id.to_string()); + drop(config); + self.save()?; + + Ok(()) + } + + /// Get the active instance + pub fn get_active_instance(&self) -> Option<Instance> { + let config = self.instances.lock().unwrap(); + config + .active_instance_id + .as_ref() + .and_then(|id| config.instances.iter().find(|i| i.id == *id)) + .cloned() + } + + /// Get the game directory for an instance + pub fn get_instance_game_dir(&self, id: &str) -> Option<PathBuf> { + self.get_instance(id).map(|i| i.game_dir) + } + + /// Duplicate an instance + pub fn duplicate_instance( + &self, + id: &str, + new_name: String, + app_handle: &AppHandle, + ) -> Result<Instance, String> { + let source_instance = self + .get_instance(id) + .ok_or_else(|| format!("Instance {} not found", id))?; + + // Create new instance + let mut new_instance = self.create_instance(new_name, app_handle)?; + + // Copy instance properties + new_instance.version_id = source_instance.version_id.clone(); + new_instance.mod_loader = source_instance.mod_loader.clone(); + new_instance.mod_loader_version = source_instance.mod_loader_version.clone(); + new_instance.notes = source_instance.notes.clone(); + + // Copy directory contents + if source_instance.game_dir.exists() { + copy_dir_all(&source_instance.game_dir, &new_instance.game_dir) + .map_err(|e| format!("Failed to copy instance directory: {}", e))?; + } + + self.update_instance(new_instance.clone())?; + + Ok(new_instance) + } +} + +/// Copy a directory recursively +fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), std::io::Error> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?; + } else { + fs::copy(entry.path(), dst.join(entry.file_name()))?; + } + } + Ok(()) +} + +/// Migrate legacy data to instance system +pub fn migrate_legacy_data( + app_handle: &AppHandle, + instance_state: &InstanceState, +) -> Result<(), String> { + let app_dir = app_handle.path().app_data_dir().unwrap(); + let old_versions_dir = app_dir.join("versions"); + let old_libraries_dir = app_dir.join("libraries"); + let old_assets_dir = app_dir.join("assets"); + + // Check if legacy data exists + let has_legacy_data = + old_versions_dir.exists() || old_libraries_dir.exists() || old_assets_dir.exists(); + + if !has_legacy_data { + return Ok(()); // No legacy data to migrate + } + + // Check if instances already exist + let config = instance_state.instances.lock().unwrap(); + if !config.instances.is_empty() { + drop(config); + return Ok(()); // Already have instances, skip migration + } + drop(config); + + // Create default instance + let default_instance = instance_state + .create_instance("Default".to_string(), app_handle) + .map_err(|e| format!("Failed to create default instance: {}", e))?; + + let new_versions_dir = default_instance.game_dir.join("versions"); + let new_libraries_dir = default_instance.game_dir.join("libraries"); + let new_assets_dir = default_instance.game_dir.join("assets"); + + // Move legacy data + if old_versions_dir.exists() { + if new_versions_dir.exists() { + // Merge directories + copy_dir_all(&old_versions_dir, &new_versions_dir) + .map_err(|e| format!("Failed to migrate versions: {}", e))?; + } else { + fs::rename(&old_versions_dir, &new_versions_dir) + .map_err(|e| format!("Failed to migrate versions: {}", e))?; + } + } + + if old_libraries_dir.exists() { + if new_libraries_dir.exists() { + copy_dir_all(&old_libraries_dir, &new_libraries_dir) + .map_err(|e| format!("Failed to migrate libraries: {}", e))?; + } else { + fs::rename(&old_libraries_dir, &new_libraries_dir) + .map_err(|e| format!("Failed to migrate libraries: {}", e))?; + } + } + + if old_assets_dir.exists() { + if new_assets_dir.exists() { + copy_dir_all(&old_assets_dir, &new_assets_dir) + .map_err(|e| format!("Failed to migrate assets: {}", e))?; + } else { + fs::rename(&old_assets_dir, &new_assets_dir) + .map_err(|e| format!("Failed to migrate assets: {}", e))?; + } + } + + Ok(()) +} diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs index 8341138..0c7769b 100644 --- a/src-tauri/src/core/java.rs +++ b/src-tauri/src/core/java.rs @@ -1,16 +1,30 @@ use serde::{Deserialize, Serialize}; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; use std::path::PathBuf; use std::process::Command; use tauri::AppHandle; use tauri::Emitter; use tauri::Manager; -use crate::core::downloader::{self, JavaDownloadProgress, DownloadQueue, PendingJavaDownload}; +use crate::core::downloader::{self, DownloadQueue, JavaDownloadProgress, PendingJavaDownload}; use crate::utils::zip; const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3"; const CACHE_DURATION_SECS: u64 = 24 * 60 * 60; // 24 hours +/// Helper to strip UNC prefix on Windows (\\?\) +fn strip_unc_prefix(path: PathBuf) -> PathBuf { + #[cfg(target_os = "windows")] + { + let s = path.to_string_lossy().to_string(); + if s.starts_with(r"\\?\") { + return PathBuf::from(&s[4..]); + } + } + path +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JavaInstallation { pub path: String, @@ -58,7 +72,7 @@ pub struct JavaReleaseInfo { } /// Java catalog containing all available versions -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct JavaCatalog { pub releases: Vec<JavaReleaseInfo>, pub available_major_versions: Vec<u32>, @@ -66,17 +80,6 @@ pub struct JavaCatalog { pub cached_at: u64, } -impl Default for JavaCatalog { - fn default() -> Self { - Self { - releases: Vec::new(), - available_major_versions: Vec::new(), - lts_versions: Vec::new(), - cached_at: 0, - } - } -} - /// Adoptium `/v3/assets/latest/{version}/hotspot` API response structures #[derive(Debug, Clone, Deserialize)] pub struct AdoptiumAsset { @@ -86,6 +89,7 @@ pub struct AdoptiumAsset { } #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct AdoptiumBinary { pub os: String, pub architecture: String, @@ -104,6 +108,7 @@ pub struct AdoptiumPackage { } #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct AdoptiumVersionData { pub major: u32, pub minor: u32, @@ -114,6 +119,7 @@ pub struct AdoptiumVersionData { /// Adoptium available releases response #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct AvailableReleases { pub available_releases: Vec<u32>, pub available_lts_releases: Vec<u32>, @@ -231,6 +237,7 @@ pub fn save_catalog_cache(app_handle: &AppHandle, catalog: &JavaCatalog) -> Resu } /// Clear Java catalog cache +#[allow(dead_code)] pub fn clear_catalog_cache(app_handle: &AppHandle) -> Result<(), String> { let cache_path = get_catalog_cache_path(app_handle); if cache_path.exists() { @@ -240,7 +247,10 @@ pub fn clear_catalog_cache(app_handle: &AppHandle) -> Result<(), String> { } /// Fetch complete Java catalog from Adoptium API with platform availability check -pub async fn fetch_java_catalog(app_handle: &AppHandle, force_refresh: bool) -> Result<JavaCatalog, String> { +pub async fn fetch_java_catalog( + app_handle: &AppHandle, + force_refresh: bool, +) -> Result<JavaCatalog, String> { // Check cache first unless force refresh if !force_refresh { if let Some(cached) = load_cached_catalog(app_handle) { @@ -294,7 +304,9 @@ pub async fn fetch_java_catalog(app_handle: &AppHandle, force_refresh: bool) -> file_size: asset.binary.package.size, checksum: asset.binary.package.checksum, download_url: asset.binary.package.link, - is_lts: available.available_lts_releases.contains(major_version), + is_lts: available + .available_lts_releases + .contains(major_version), is_available: true, architecture: asset.binary.architecture.clone(), }); @@ -547,7 +559,11 @@ pub async fn download_and_install_java( // Linux/Windows: jdk-xxx/bin/java let java_home = version_dir.join(&top_level_dir); let java_bin = if cfg!(target_os = "macos") { - java_home.join("Contents").join("Home").join("bin").join("java") + java_home + .join("Contents") + .join("Home") + .join("bin") + .join("java") } else if cfg!(windows) { java_home.join("bin").join("java.exe") } else { @@ -561,6 +577,10 @@ pub async fn download_and_install_java( )); } + // Resolve symlinks and strip UNC prefix to ensure clean path + let java_bin = std::fs::canonicalize(&java_bin).map_err(|e| e.to_string())?; + let java_bin = strip_unc_prefix(java_bin); + // 9. Verify installation let installation = check_java_installation(&java_bin) .ok_or_else(|| "Failed to verify Java installation".to_string())?; @@ -634,16 +654,22 @@ fn get_java_candidates() -> Vec<PathBuf> { let mut candidates = Vec::new(); // Check PATH first - if let Ok(output) = Command::new(if cfg!(windows) { "where" } else { "which" }) - .arg("java") - .output() - { + let mut cmd = Command::new(if cfg!(windows) { "where" } else { "which" }); + cmd.arg("java"); + #[cfg(target_os = "windows")] + cmd.creation_flags(0x08000000); + + if let Ok(output) = cmd.output() { if output.status.success() { let paths = String::from_utf8_lossy(&output.stdout); for line in paths.lines() { let path = PathBuf::from(line.trim()); if path.exists() { - candidates.push(path); + // Resolve symlinks (important for Windows javapath wrapper) + let resolved = std::fs::canonicalize(&path).unwrap_or(path); + // Strip UNC prefix if present to keep paths clean + let final_path = strip_unc_prefix(resolved); + candidates.push(final_path); } } } @@ -786,7 +812,12 @@ fn get_java_candidates() -> Vec<PathBuf> { /// Check a specific Java installation and get its version info fn check_java_installation(path: &PathBuf) -> Option<JavaInstallation> { - let output = Command::new(path).arg("-version").output().ok()?; + let mut cmd = Command::new(path); + cmd.arg("-version"); + #[cfg(target_os = "windows")] + cmd.creation_flags(0x08000000); + + let output = cmd.output().ok()?; // Java outputs version info to stderr let version_output = String::from_utf8_lossy(&output.stderr); @@ -850,6 +881,64 @@ pub fn get_recommended_java(required_major_version: Option<u64>) -> Option<JavaI } } +/// Get compatible Java for a specific Minecraft version with upper bound +/// For older Minecraft versions (1.13.x and below), we need Java 8 specifically +/// as newer Java versions have compatibility issues with old Forge versions +pub fn get_compatible_java( + app_handle: &AppHandle, + required_major_version: Option<u64>, + max_major_version: Option<u32>, +) -> Option<JavaInstallation> { + let installations = detect_all_java_installations(app_handle); + + if let Some(max_version) = max_major_version { + // Find Java version within the acceptable range + installations.into_iter().find(|java| { + let major = parse_java_version(&java.version); + let meets_min = if let Some(required) = required_major_version { + major >= required as u32 + } else { + true + }; + meets_min && major <= max_version + }) + } else if let Some(required) = required_major_version { + // Find exact match or higher (no upper bound) + installations.into_iter().find(|java| { + let major = parse_java_version(&java.version); + major >= required as u32 + }) + } else { + // Return newest + installations.into_iter().next() + } +} + +/// Check if a Java installation is compatible with the required version range +pub fn is_java_compatible( + java_path: &str, + required_major_version: Option<u64>, + max_major_version: Option<u32>, +) -> bool { + let java_path_buf = PathBuf::from(java_path); + if let Some(java) = check_java_installation(&java_path_buf) { + let major = parse_java_version(&java.version); + let meets_min = if let Some(required) = required_major_version { + major >= required as u32 + } else { + true + }; + let meets_max = if let Some(max_version) = max_major_version { + major <= max_version + } else { + true + }; + meets_min && meets_max + } else { + false + } +} + /// Detect all installed Java versions (including system installations and DropOut downloads) pub fn detect_all_java_installations(app_handle: &AppHandle) -> Vec<JavaInstallation> { let mut installations = detect_java_installations(); @@ -885,14 +974,15 @@ pub fn detect_all_java_installations(app_handle: &AppHandle) -> Vec<JavaInstalla installations } -//// Find the java executable in a directory using a limited-depth search +/// Find the java executable in a directory using a limited-depth search fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> { let bin_name = if cfg!(windows) { "java.exe" } else { "java" }; // Directly look in the bin directory let direct_bin = dir.join("bin").join(bin_name); if direct_bin.exists() { - return Some(direct_bin); + let resolved = std::fs::canonicalize(&direct_bin).unwrap_or(direct_bin); + return Some(strip_unc_prefix(resolved)); } // macOS: Contents/Home/bin/java @@ -912,13 +1002,18 @@ fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> { // Try direct bin path let nested_bin = path.join("bin").join(bin_name); if nested_bin.exists() { - return Some(nested_bin); + let resolved = std::fs::canonicalize(&nested_bin).unwrap_or(nested_bin); + return Some(strip_unc_prefix(resolved)); } // macOS: nested/Contents/Home/bin/java #[cfg(target_os = "macos")] { - let macos_nested = path.join("Contents").join("Home").join("bin").join(bin_name); + let macos_nested = path + .join("Contents") + .join("Home") + .join("bin") + .join(bin_name); if macos_nested.exists() { return Some(macos_nested); } @@ -931,7 +1026,9 @@ fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> { } /// Resume pending Java downloads from queue -pub async fn resume_pending_downloads(app_handle: &AppHandle) -> Result<Vec<JavaInstallation>, String> { +pub async fn resume_pending_downloads( + app_handle: &AppHandle, +) -> Result<Vec<JavaInstallation>, String> { let queue = DownloadQueue::load(app_handle); let mut installed = Vec::new(); @@ -978,7 +1075,12 @@ pub fn get_pending_downloads(app_handle: &AppHandle) -> Vec<PendingJavaDownload> } /// Clear a specific pending download -pub fn clear_pending_download(app_handle: &AppHandle, major_version: u32, image_type: &str) -> Result<(), String> { +#[allow(dead_code)] +pub fn clear_pending_download( + app_handle: &AppHandle, + major_version: u32, + image_type: &str, +) -> Result<(), String> { let mut queue = DownloadQueue::load(app_handle); queue.remove(major_version, image_type); queue.save(app_handle) diff --git a/src-tauri/src/core/manifest.rs b/src-tauri/src/core/manifest.rs index bae87c9..637b935 100644 --- a/src-tauri/src/core/manifest.rs +++ b/src-tauri/src/core/manifest.rs @@ -25,6 +25,13 @@ pub struct Version { pub time: String, #[serde(rename = "releaseTime")] pub release_time: String, + /// Java version requirement (major version number) + /// This is populated from the version JSON file if the version is installed locally + #[serde(skip_serializing_if = "Option::is_none")] + pub java_version: Option<u64>, + /// Whether this version is installed locally + #[serde(rename = "isInstalled", skip_serializing_if = "Option::is_none")] + pub is_installed: Option<bool>, } pub async fn fetch_version_manifest() -> Result<VersionManifest, Box<dyn Error + Send + Sync>> { @@ -45,7 +52,7 @@ pub async fn fetch_version_manifest() -> Result<VersionManifest, Box<dyn Error + /// # Returns /// The parsed `GameVersion` if found, or an error if not found. pub async fn load_local_version( - game_dir: &PathBuf, + game_dir: &std::path::Path, version_id: &str, ) -> Result<GameVersion, Box<dyn Error + Send + Sync>> { let json_path = game_dir @@ -102,7 +109,7 @@ pub async fn fetch_vanilla_version( /// # Returns /// A fully resolved `GameVersion` ready for launching. pub async fn load_version( - game_dir: &PathBuf, + game_dir: &std::path::Path, version_id: &str, ) -> Result<GameVersion, Box<dyn Error + Send + Sync>> { // Try loading from local first @@ -138,7 +145,7 @@ pub async fn load_version( /// # Returns /// The path where the JSON was saved. pub async fn save_local_version( - game_dir: &PathBuf, + game_dir: &std::path::Path, version: &GameVersion, ) -> Result<PathBuf, Box<dyn Error + Send + Sync>> { let version_dir = game_dir.join("versions").join(&version.id); @@ -158,8 +165,9 @@ pub async fn save_local_version( /// /// # Returns /// A list of version IDs found in the versions directory. +#[allow(dead_code)] pub async fn list_local_versions( - game_dir: &PathBuf, + game_dir: &std::path::Path, ) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> { let versions_dir = game_dir.join("versions"); let mut versions = Vec::new(); diff --git a/src-tauri/src/core/maven.rs b/src-tauri/src/core/maven.rs index 8c89768..760e68b 100644 --- a/src-tauri/src/core/maven.rs +++ b/src-tauri/src/core/maven.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; /// Known Maven repository URLs for mod loaders +#[allow(dead_code)] pub const MAVEN_CENTRAL: &str = "https://repo1.maven.org/maven2/"; pub const FABRIC_MAVEN: &str = "https://maven.fabricmc.net/"; pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net/"; @@ -114,7 +115,7 @@ impl MavenCoordinate { /// /// # Returns /// The full path where the library should be stored - pub fn to_local_path(&self, libraries_dir: &PathBuf) -> PathBuf { + pub fn to_local_path(&self, libraries_dir: &std::path::Path) -> PathBuf { let rel_path = self.to_path(); libraries_dir.join(rel_path.replace('/', std::path::MAIN_SEPARATOR_STR)) } @@ -183,7 +184,7 @@ pub fn resolve_library_url( /// /// # Returns /// The path where the library should be stored -pub fn get_library_path(name: &str, libraries_dir: &PathBuf) -> Option<PathBuf> { +pub fn get_library_path(name: &str, libraries_dir: &std::path::Path) -> Option<PathBuf> { let coord = MavenCoordinate::parse(name)?; Some(coord.to_local_path(libraries_dir)) } diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 3c09a76..dcbd47a 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,10 +1,12 @@ pub mod account_storage; +pub mod assistant; pub mod auth; pub mod config; pub mod downloader; pub mod fabric; pub mod forge; pub mod game_version; +pub mod instance; pub mod java; pub mod manifest; pub mod maven; diff --git a/src-tauri/src/core/version_merge.rs b/src-tauri/src/core/version_merge.rs index fe6b3cd..098d271 100644 --- a/src-tauri/src/core/version_merge.rs +++ b/src-tauri/src/core/version_merge.rs @@ -101,6 +101,7 @@ fn merge_json_arrays( /// /// # Returns /// `true` if the version has an `inheritsFrom` field that needs resolution. +#[allow(dead_code)] pub fn needs_inheritance_resolution(version: &GameVersion) -> bool { version.inherits_from.is_some() } @@ -116,6 +117,7 @@ pub fn needs_inheritance_resolution(version: &GameVersion) -> bool { /// /// # Returns /// A fully merged `GameVersion` with all inheritance resolved. +#[allow(dead_code)] pub async fn resolve_inheritance<F, Fut>( version: GameVersion, version_loader: F, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3671166..2871b03 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,12 +1,12 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use serde::Serialize; use std::process::Stdio; use std::sync::Mutex; use tauri::{Emitter, Manager, State, Window}; // Added Emitter use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::Command; -use serde::Serialize; // Added Serialize +use tokio::process::Command; // Added Serialize #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; @@ -67,11 +67,17 @@ async fn start_game( window: Window, auth_state: State<'_, core::auth::AccountState>, config_state: State<'_, core::config::ConfigState>, + assistant_state: State<'_, core::assistant::AssistantState>, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, version_id: String, ) -> Result<String, String> { emit_log!( window, - format!("Starting game launch for version: {}", version_id) + format!( + "Starting game launch for version: {} in instance: {}", + version_id, instance_id + ) ); // Check for active account @@ -83,14 +89,7 @@ async fn start_game( .clone() .ok_or("No active account found. Please login first.")?; - let account_type = match &account { - core::auth::Account::Offline(_) => "Offline", - core::auth::Account::Microsoft(_) => "Microsoft", - }; - emit_log!( - window, - format!("Account found: {} ({})", account.username(), account_type) - ); + emit_log!(window, format!("Account found: {}", account.username())); let config = config_state.config.lock().unwrap().clone(); emit_log!(window, format!("Java path: {}", config.java_path)); @@ -99,14 +98,10 @@ async fn start_game( format!("Memory: {}MB - {}MB", config.min_memory, config.max_memory) ); - // Get App Data Directory (e.g., ~/.local/share/com.dropout.launcher or similar) - // The identifier is set in tauri.conf.json. - // If not accessible, use a specific logic. - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; + // Get game directory from instance + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // Ensure game directory exists tokio::fs::create_dir_all(&game_dir) @@ -123,10 +118,11 @@ async fn start_game( // First, load the local version to get the original inheritsFrom value // (before merge clears it) - let original_inherits_from = match core::manifest::load_local_version(&game_dir, &version_id).await { - Ok(local_version) => local_version.inherits_from.clone(), - Err(_) => None, - }; + let original_inherits_from = + match core::manifest::load_local_version(&game_dir, &version_id).await { + Ok(local_version) => local_version.inherits_from.clone(), + Err(_) => None, + }; let version_details = core::manifest::load_version(&game_dir, &version_id) .await @@ -142,8 +138,123 @@ async fn start_game( // Determine the actual minecraft version for client.jar // (for modded versions, this is the parent vanilla version) - let minecraft_version = original_inherits_from - .unwrap_or_else(|| version_id.clone()); + let minecraft_version = original_inherits_from.unwrap_or_else(|| version_id.clone()); + + // Get required Java version from version file's javaVersion field + // The version file (after merging with parent) should contain the correct javaVersion + let required_java_major = version_details + .java_version + .as_ref() + .map(|jv| jv.major_version); + + // For older Minecraft versions (1.13.x and below), if javaVersion specifies Java 8, + // we should only allow Java 8 (not higher) due to compatibility issues with old Forge + // For newer versions, javaVersion.majorVersion is the minimum required version + let max_java_major = if let Some(required) = required_java_major { + // If version file specifies Java 8, enforce it as maximum (old versions need exactly Java 8) + // For Java 9+, allow that version or higher + if required <= 8 { + Some(8) + } else { + None // No upper bound for Java 9+ + } + } else { + // If version file doesn't specify javaVersion, this shouldn't happen for modern versions + // But if it does, we can't determine compatibility - log a warning + emit_log!( + window, + "Warning: Version file does not specify javaVersion. Using system default Java." + .to_string() + ); + None + }; + + // Check if configured Java is compatible + let app_handle = window.app_handle(); + let mut java_path_to_use = config.java_path.clone(); + if !java_path_to_use.is_empty() && java_path_to_use != "java" { + let is_compatible = + core::java::is_java_compatible(&java_path_to_use, required_java_major, max_java_major); + + if !is_compatible { + emit_log!( + window, + format!( + "Configured Java version may not be compatible. Looking for compatible Java..." + ) + ); + + // Try to find a compatible Java version + if let Some(compatible_java) = + core::java::get_compatible_java(app_handle, required_java_major, max_java_major) + { + emit_log!( + window, + format!( + "Found compatible Java {} at: {}", + compatible_java.version, compatible_java.path + ) + ); + java_path_to_use = compatible_java.path; + } else { + let version_constraint = if let Some(max) = max_java_major { + if let Some(min) = required_java_major { + if min == max as u64 { + format!("Java {}", min) + } else { + format!("Java {} to {}", min, max) + } + } else { + format!("Java {} (or lower)", max) + } + } else if let Some(min) = required_java_major { + format!("Java {} or higher", min) + } else { + "any Java version".to_string() + }; + + return Err(format!( + "No compatible Java installation found. This version requires {}. Please install a compatible Java version in settings.", + version_constraint + )); + } + } + } else { + // No Java configured, try to find a compatible one + if let Some(compatible_java) = + core::java::get_compatible_java(app_handle, required_java_major, max_java_major) + { + emit_log!( + window, + format!( + "Using Java {} at: {}", + compatible_java.version, compatible_java.path + ) + ); + java_path_to_use = compatible_java.path; + } else { + let version_constraint = if let Some(max) = max_java_major { + if let Some(min) = required_java_major { + if min == max as u64 { + format!("Java {}", min) + } else { + format!("Java {} to {}", min, max) + } + } else { + format!("Java {} (or lower)", max) + } + } else if let Some(min) = required_java_major { + format!("Java {} or higher", min) + } else { + "any Java version".to_string() + }; + + return Err(format!( + "No compatible Java installation found. This version requires {}. Please install a compatible Java version in settings.", + version_constraint + )); + } + } // 2. Prepare download tasks emit_log!(window, "Preparing download tasks...".to_string()); @@ -527,17 +638,67 @@ async fn start_game( window, format!("Preparing to launch game with {} arguments...", args.len()) ); - // Debug: Log arguments (only first few to avoid spam) - if args.len() > 10 { - emit_log!(window, format!("First 10 args: {:?}", &args[..10])); - } + + // Format Java command with sensitive information masked + let masked_args: Vec<String> = args + .iter() + .enumerate() + .map(|(i, arg)| { + // Check if previous argument was a sensitive flag + if i > 0 { + let prev_arg = &args[i - 1]; + if prev_arg == "--accessToken" || prev_arg == "--uuid" { + return "***".to_string(); + } + } + + // Mask sensitive argument values + if arg == "--accessToken" || arg == "--uuid" { + arg.clone() + } else if arg.starts_with("token:") { + // Mask token: prefix tokens (Session ID format) + "token:***".to_string() + } else if arg.len() > 100 + && arg.contains('.') + && !arg.contains('/') + && !arg.contains('\\') + && !arg.contains(':') + { + // Likely a JWT token (very long string with dots, no paths) + "***".to_string() + } else if arg.len() == 36 + && arg.contains('-') + && arg.chars().all(|c| c.is_ascii_hexdigit() || c == '-') + { + // Likely a UUID (36 chars with dashes) + "***".to_string() + } else { + arg.clone() + } + }) + .collect(); + + // Format as actual Java command (properly quote arguments with spaces) + let masked_args_str: Vec<String> = masked_args + .iter() + .map(|arg| { + if arg.contains(' ') { + format!("\"{}\"", arg) + } else { + arg.clone() + } + }) + .collect(); + + let java_command = format!("{} {}", java_path_to_use, masked_args_str.join(" ")); + emit_log!(window, format!("Java Command: {}", java_command)); // Spawn the process emit_log!( window, - format!("Starting Java process: {}", config.java_path) + format!("Starting Java process: {}", java_path_to_use) ); - let mut command = Command::new(&config.java_path); + let mut command = Command::new(&java_path_to_use); command.args(&args); command.current_dir(&game_dir); // Run in game directory command.stdout(Stdio::piped()); @@ -557,7 +718,7 @@ async fn start_game( // Spawn and handle output let mut child = command .spawn() - .map_err(|e| format!("Failed to launch java: {}", e))?; + .map_err(|e| format!("Failed to launch Java at '{}': {}\nPlease check your Java installation and path configuration in Settings.", java_path_to_use, e))?; emit_log!(window, "Java process started successfully".to_string()); @@ -577,9 +738,11 @@ async fn start_game( ); let window_rx = window.clone(); + let assistant_arc = assistant_state.assistant.clone(); tokio::spawn(async move { let mut reader = BufReader::new(stdout).lines(); while let Ok(Some(line)) = reader.next_line().await { + assistant_arc.lock().unwrap().add_log(line.clone()); let _ = window_rx.emit("game-stdout", line); } // Emit log when stdout stream ends (game closing) @@ -587,10 +750,12 @@ async fn start_game( }); let window_rx_err = window.clone(); + let assistant_arc_err = assistant_state.assistant.clone(); let window_exit = window.clone(); tokio::spawn(async move { let mut reader = BufReader::new(stderr).lines(); while let Ok(Some(line)) = reader.next_line().await { + assistant_arc_err.lock().unwrap().add_log(line.clone()); let _ = window_rx_err.emit("game-stderr", line); } // Emit log when stderr stream ends @@ -685,29 +850,73 @@ fn parse_jvm_arguments( } #[tauri::command] -async fn get_versions() -> Result<Vec<core::manifest::Version>, String> { +async fn get_versions(window: Window) -> Result<Vec<core::manifest::Version>, String> { + let app_handle = window.app_handle(); + let game_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; + match core::manifest::fetch_version_manifest().await { - Ok(manifest) => Ok(manifest.versions), + Ok(manifest) => { + let mut versions = manifest.versions; + + // For each version, try to load Java version info and check installation status + for version in &mut versions { + // Check if version is installed + let version_dir = game_dir.join("versions").join(&version.id); + let json_path = version_dir.join(format!("{}.json", version.id)); + let client_jar_path = version_dir.join(format!("{}.jar", version.id)); + + // Version is installed if both JSON and client jar exist + let is_installed = json_path.exists() && client_jar_path.exists(); + version.is_installed = Some(is_installed); + + // If installed, try to load the version JSON to get javaVersion + if is_installed { + if let Ok(game_version) = + core::manifest::load_local_version(&game_dir, &version.id).await + { + if let Some(java_ver) = game_version.java_version { + version.java_version = Some(java_ver.major_version); + } + } + } + } + + Ok(versions) + } Err(e) => Err(e.to_string()), } } /// Check if a version is installed (has client.jar) #[tauri::command] -async fn check_version_installed(window: Window, version_id: String) -> Result<bool, String> { - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; +async fn check_version_installed( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + version_id: String, +) -> Result<bool, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // For modded versions, check the parent vanilla version let minecraft_version = if version_id.starts_with("fabric-loader-") { // Format: fabric-loader-X.X.X-1.20.4 - version_id.split('-').last().unwrap_or(&version_id).to_string() + version_id + .split('-') + .next_back() + .unwrap_or(&version_id) + .to_string() } else if version_id.contains("-forge-") { // Format: 1.20.4-forge-49.0.38 - version_id.split("-forge-").next().unwrap_or(&version_id).to_string() + version_id + .split("-forge-") + .next() + .unwrap_or(&version_id) + .to_string() } else { version_id.clone() }; @@ -725,19 +934,24 @@ async fn check_version_installed(window: Window, version_id: String) -> Result<b async fn install_version( window: Window, config_state: State<'_, core::config::ConfigState>, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, version_id: String, ) -> Result<(), String> { emit_log!( window, - format!("Starting installation for version: {}", version_id) + format!( + "Starting installation for version: {} in instance: {}", + version_id, instance_id + ) ); let config = config_state.config.lock().unwrap().clone(); - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; + + // Get game directory from instance + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // Ensure game directory exists tokio::fs::create_dir_all(&game_dir) @@ -753,21 +967,24 @@ async fn install_version( ); // First, try to fetch the vanilla version from Mojang and save it locally - let version_details = match core::manifest::load_local_version(&game_dir, &version_id).await { + let _version_details = match core::manifest::load_local_version(&game_dir, &version_id).await { Ok(v) => v, Err(_) => { // Not found locally, fetch from Mojang - emit_log!(window, format!("Fetching version {} from Mojang...", version_id)); + emit_log!( + window, + format!("Fetching version {} from Mojang...", version_id) + ); let fetched = core::manifest::fetch_vanilla_version(&version_id) .await .map_err(|e| e.to_string())?; - + // Save the version JSON locally emit_log!(window, format!("Saving version JSON...")); core::manifest::save_local_version(&game_dir, &fetched) .await .map_err(|e| e.to_string())?; - + fetched } }; @@ -983,6 +1200,9 @@ async fn install_version( format!("Installation of {} completed successfully!", version_id) ); + // Emit event to notify frontend that version installation is complete + let _ = window.emit("version-installed", &version_id); + Ok(()) } @@ -1060,6 +1280,38 @@ async fn save_settings( } #[tauri::command] +async fn get_config_path(state: State<'_, core::config::ConfigState>) -> Result<String, String> { + Ok(state.file_path.to_string_lossy().to_string()) +} + +#[tauri::command] +async fn read_raw_config(state: State<'_, core::config::ConfigState>) -> Result<String, String> { + tokio::fs::read_to_string(&state.file_path) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn save_raw_config( + state: State<'_, core::config::ConfigState>, + content: String, +) -> Result<(), String> { + // Validate JSON + let new_config: core::config::LauncherConfig = + serde_json::from_str(&content).map_err(|e| format!("Invalid JSON: {}", e))?; + + // Save to file + tokio::fs::write(&state.file_path, &content) + .await + .map_err(|e| e.to_string())?; + + // Update in-memory state + *state.config.lock().unwrap() = new_config; + + Ok(()) +} + +#[tauri::command] async fn start_microsoft_login() -> Result<core::auth::DeviceCodeResponse, String> { core::auth::start_device_flow().await } @@ -1170,7 +1422,9 @@ async fn refresh_account( /// Detect Java installations on the system #[tauri::command] -async fn detect_java(app_handle: tauri::AppHandle) -> Result<Vec<core::java::JavaInstallation>, String> { +async fn detect_java( + app_handle: tauri::AppHandle, +) -> Result<Vec<core::java::JavaInstallation>, String> { Ok(core::java::detect_all_java_installations(&app_handle)) } @@ -1286,22 +1540,22 @@ async fn get_fabric_loaders_for_version( #[tauri::command] async fn install_fabric( window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, game_version: String, loader_version: String, ) -> Result<core::fabric::InstalledFabricVersion, String> { emit_log!( window, format!( - "Installing Fabric {} for Minecraft {}...", - loader_version, game_version + "Installing Fabric {} for Minecraft {} in instance {}...", + loader_version, game_version, instance_id ) ); - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version) .await @@ -1312,23 +1566,172 @@ async fn install_fabric( format!("Fabric installed successfully: {}", result.id) ); + // Emit event to notify frontend + let _ = window.emit("fabric-installed", &result.id); + Ok(result) } /// List installed Fabric versions #[tauri::command] -async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, String> { - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; +async fn list_installed_fabric_versions( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<Vec<String>, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; core::fabric::list_installed_fabric_versions(&game_dir) .await .map_err(|e| e.to_string()) } +/// Get Java version requirement for a specific version +#[tauri::command] +async fn get_version_java_version( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + version_id: String, +) -> Result<Option<u64>, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; + + // Try to load the version JSON to get javaVersion + match core::manifest::load_version(&game_dir, &version_id).await { + Ok(game_version) => Ok(game_version.java_version.map(|jv| jv.major_version)), + Err(_) => Ok(None), // Version not found or can't be loaded + } +} + +/// Version metadata for display in the UI +#[derive(serde::Serialize)] +struct VersionMetadata { + id: String, + #[serde(rename = "javaVersion")] + java_version: Option<u64>, + #[serde(rename = "isInstalled")] + is_installed: bool, +} + +/// Delete a version (remove version directory) +#[tauri::command] +async fn delete_version( + window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + version_id: String, +) -> Result<(), String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; + + let version_dir = game_dir.join("versions").join(&version_id); + + if !version_dir.exists() { + return Err(format!("Version {} not found", version_id)); + } + + // Remove the entire version directory + tokio::fs::remove_dir_all(&version_dir) + .await + .map_err(|e| format!("Failed to delete version: {}", e))?; + + // Emit event to notify frontend + let _ = window.emit("version-deleted", &version_id); + + Ok(()) +} + +/// Get detailed metadata for a specific version +#[tauri::command] +async fn get_version_metadata( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + version_id: String, +) -> Result<VersionMetadata, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; + + // Initialize metadata + let mut metadata = VersionMetadata { + id: version_id.clone(), + java_version: None, + is_installed: false, + }; + + // Check if version is in manifest and get Java version if available + if let Ok(manifest) = core::manifest::fetch_version_manifest().await { + if let Some(version_entry) = manifest.versions.iter().find(|v| v.id == version_id) { + // Note: version_entry.java_version is only set if version is installed locally + // For uninstalled versions, we'll fetch from remote below + if let Some(java_ver) = version_entry.java_version { + metadata.java_version = Some(java_ver); + } + } + } + + // Check if version is installed (both JSON and client jar must exist) + let version_dir = game_dir.join("versions").join(&version_id); + let json_path = version_dir.join(format!("{}.json", version_id)); + + // For modded versions, check the parent vanilla version's client jar + let client_jar_path = if version_id.starts_with("fabric-loader-") { + // Format: fabric-loader-X.X.X-1.20.4 + let minecraft_version = version_id + .split('-') + .next_back() + .unwrap_or(&version_id) + .to_string(); + game_dir + .join("versions") + .join(&minecraft_version) + .join(format!("{}.jar", minecraft_version)) + } else if version_id.contains("-forge-") { + // Format: 1.20.4-forge-49.0.38 + let minecraft_version = version_id + .split("-forge-") + .next() + .unwrap_or(&version_id) + .to_string(); + game_dir + .join("versions") + .join(&minecraft_version) + .join(format!("{}.jar", minecraft_version)) + } else { + version_dir.join(format!("{}.jar", version_id)) + }; + + metadata.is_installed = json_path.exists() && client_jar_path.exists(); + + // Try to get Java version - from local if installed, or from remote if not + if metadata.is_installed { + // If installed, load from local version JSON + if let Ok(game_version) = core::manifest::load_version(&game_dir, &version_id).await { + if let Some(java_ver) = game_version.java_version { + metadata.java_version = Some(java_ver.major_version); + } + } + } else if metadata.java_version.is_none() { + // If not installed and we don't have Java version yet, try to fetch from remote + // This is for vanilla versions that are not installed + if !version_id.starts_with("fabric-loader-") && !version_id.contains("-forge-") { + if let Ok(game_version) = core::manifest::fetch_vanilla_version(&version_id).await { + if let Some(java_ver) = game_version.java_version { + metadata.java_version = Some(java_ver.major_version); + } + } + } + } + + Ok(metadata) +} + /// Installed version info #[derive(serde::Serialize)] struct InstalledVersion { @@ -1340,12 +1743,14 @@ struct InstalledVersion { /// List all installed versions from the data directory /// Simply lists all folders in the versions directory without validation #[tauri::command] -async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion>, String> { - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; +async fn list_installed_versions( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<Vec<InstalledVersion>, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; let versions_dir = game_dir.join("versions"); let mut installed = Vec::new(); @@ -1425,15 +1830,15 @@ async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion> /// Check if Fabric is installed for a specific version #[tauri::command] async fn is_fabric_installed( - window: Window, + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, game_version: String, loader_version: String, ) -> Result<bool, String> { - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; Ok(core::fabric::is_fabric_installed( &game_dir, @@ -1465,37 +1870,40 @@ async fn get_forge_versions_for_game( async fn install_forge( window: Window, config_state: State<'_, core::config::ConfigState>, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, game_version: String, forge_version: String, ) -> Result<core::forge::InstalledForgeVersion, String> { emit_log!( window, format!( - "Installing Forge {} for Minecraft {}...", - forge_version, game_version + "Installing Forge {} for Minecraft {} in instance {}...", + forge_version, game_version, instance_id ) ); - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // Get Java path from config or detect let config = config_state.config.lock().unwrap().clone(); + let app_handle = window.app_handle(); let java_path_str = if !config.java_path.is_empty() && config.java_path != "java" { config.java_path.clone() } else { // Try to find a suitable Java installation - let javas = core::java::detect_all_java_installations(&app_handle); + let javas = core::java::detect_all_java_installations(app_handle); if let Some(java) = javas.first() { java.path.clone() } else { - return Err("No Java installation found. Please configure Java in settings.".to_string()); + return Err( + "No Java installation found. Please configure Java in settings.".to_string(), + ); } }; - let java_path = std::path::PathBuf::from(&java_path_str); + let java_path = utils::path::normalize_java_path(&java_path_str)?; emit_log!(window, "Running Forge installer...".to_string()); @@ -1504,7 +1912,10 @@ async fn install_forge( .await .map_err(|e| format!("Forge installer failed: {}", e))?; - emit_log!(window, "Forge installer completed, creating version profile...".to_string()); + emit_log!( + window, + "Forge installer completed, creating version profile...".to_string() + ); // Now create the version JSON let result = core::forge::install_forge(&game_dir, &game_version, &forge_version) @@ -1516,6 +1927,9 @@ async fn install_forge( format!("Forge installed successfully: {}", result.id) ); + // Emit event to notify frontend + let _ = window.emit("forge-installed", &result.id); + Ok(result) } @@ -1551,7 +1965,7 @@ async fn get_github_releases() -> Result<Vec<GithubRelease>, String> { r["name"].as_str(), r["published_at"].as_str(), r["body"].as_str(), - r["html_url"].as_str() + r["html_url"].as_str(), ) { result.push(GithubRelease { tag_name: tag.to_string(), @@ -1593,8 +2007,7 @@ async fn upload_to_pastebin( match service.as_str() { "pastebin.com" => { - let api_key = api_key - .ok_or("Pastebin API Key not configured in settings")?; + let api_key = api_key.ok_or("Pastebin API Key not configured in settings")?; let res = client .post("https://pastebin.com/api/api_post.php") @@ -1640,6 +2053,139 @@ async fn upload_to_pastebin( } } +#[tauri::command] +async fn assistant_check_health( + assistant_state: State<'_, core::assistant::AssistantState>, + config_state: State<'_, core::config::ConfigState>, +) -> Result<bool, String> { + let assistant = assistant_state.assistant.lock().unwrap().clone(); + let config = config_state.config.lock().unwrap().clone(); + Ok(assistant.check_health(&config.assistant).await) +} + +#[tauri::command] +async fn assistant_chat( + assistant_state: State<'_, core::assistant::AssistantState>, + config_state: State<'_, core::config::ConfigState>, + messages: Vec<core::assistant::Message>, +) -> Result<core::assistant::Message, String> { + let assistant = assistant_state.assistant.lock().unwrap().clone(); + let config = config_state.config.lock().unwrap().clone(); + assistant.chat(messages, &config.assistant).await +} + +#[tauri::command] +async fn list_ollama_models( + assistant_state: State<'_, core::assistant::AssistantState>, + endpoint: String, +) -> Result<Vec<core::assistant::ModelInfo>, String> { + let assistant = assistant_state.assistant.lock().unwrap().clone(); + assistant.list_ollama_models(&endpoint).await +} + +#[tauri::command] +async fn list_openai_models( + assistant_state: State<'_, core::assistant::AssistantState>, + config_state: State<'_, core::config::ConfigState>, +) -> Result<Vec<core::assistant::ModelInfo>, String> { + let assistant = assistant_state.assistant.lock().unwrap().clone(); + let config = config_state.config.lock().unwrap().clone(); + assistant.list_openai_models(&config.assistant).await +} + +// ==================== Instance Management Commands ==================== + +/// Create a new instance +#[tauri::command] +async fn create_instance( + window: Window, + state: State<'_, core::instance::InstanceState>, + name: String, +) -> Result<core::instance::Instance, String> { + let app_handle = window.app_handle(); + state.create_instance(name, app_handle) +} + +/// Delete an instance +#[tauri::command] +async fn delete_instance( + state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<(), String> { + state.delete_instance(&instance_id) +} + +/// Update an instance +#[tauri::command] +async fn update_instance( + state: State<'_, core::instance::InstanceState>, + instance: core::instance::Instance, +) -> Result<(), String> { + state.update_instance(instance) +} + +/// Get all instances +#[tauri::command] +async fn list_instances( + state: State<'_, core::instance::InstanceState>, +) -> Result<Vec<core::instance::Instance>, String> { + Ok(state.list_instances()) +} + +/// Get a single instance by ID +#[tauri::command] +async fn get_instance( + state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<core::instance::Instance, String> { + state + .get_instance(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id)) +} + +/// Set the active instance +#[tauri::command] +async fn set_active_instance( + state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<(), String> { + state.set_active_instance(&instance_id) +} + +/// Get the active instance +#[tauri::command] +async fn get_active_instance( + state: State<'_, core::instance::InstanceState>, +) -> Result<Option<core::instance::Instance>, String> { + Ok(state.get_active_instance()) +} + +/// Duplicate an instance +#[tauri::command] +async fn duplicate_instance( + window: Window, + state: State<'_, core::instance::InstanceState>, + instance_id: String, + new_name: String, +) -> Result<core::instance::Instance, String> { + let app_handle = window.app_handle(); + state.duplicate_instance(&instance_id, new_name, app_handle) +} + +#[tauri::command] +async fn assistant_chat_stream( + window: tauri::Window, + assistant_state: State<'_, core::assistant::AssistantState>, + config_state: State<'_, core::config::ConfigState>, + messages: Vec<core::assistant::Message>, +) -> Result<String, String> { + let assistant = assistant_state.assistant.lock().unwrap().clone(); + let config = config_state.config.lock().unwrap().clone(); + assistant + .chat_stream(messages, &config.assistant, &window) + .await +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_fs::init()) @@ -1647,10 +2193,21 @@ fn main() { .plugin(tauri_plugin_shell::init()) .manage(core::auth::AccountState::new()) .manage(MsRefreshTokenState::new()) + .manage(core::assistant::AssistantState::new()) .setup(|app| { let config_state = core::config::ConfigState::new(app.handle()); app.manage(config_state); + // Initialize instance state + let instance_state = core::instance::InstanceState::new(app.handle()); + + // Migrate legacy data if needed + if let Err(e) = core::instance::migrate_legacy_data(app.handle(), &instance_state) { + eprintln!("[Startup] Warning: Failed to migrate legacy data: {}", e); + } + + app.manage(instance_state); + // Load saved account on startup let app_dir = app.path().app_data_dir().unwrap(); let storage = core::account_storage::AccountStorage::new(app_dir); @@ -1670,7 +2227,7 @@ fn main() { } // Check for pending Java downloads and notify frontend - let pending = core::java::get_pending_downloads(&app.app_handle()); + let pending = core::java::get_pending_downloads(app.app_handle()); if !pending.is_empty() { println!("[Startup] Found {} pending Java download(s)", pending.len()); let _ = app.emit("pending-java-downloads", pending.len()); @@ -1684,11 +2241,17 @@ fn main() { check_version_installed, install_version, list_installed_versions, + get_version_java_version, + get_version_metadata, + delete_version, login_offline, get_active_account, logout, get_settings, save_settings, + get_config_path, + read_raw_config, + save_raw_config, start_microsoft_login, complete_microsoft_login, refresh_account, @@ -1715,7 +2278,21 @@ fn main() { get_forge_versions_for_game, install_forge, get_github_releases, - upload_to_pastebin + upload_to_pastebin, + assistant_check_health, + assistant_chat, + assistant_chat_stream, + list_ollama_models, + list_openai_models, + // Instance management commands + create_instance, + delete_instance, + update_instance, + list_instances, + get_instance, + set_active_instance, + get_active_instance, + duplicate_instance ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index c0aed36..c9ac368 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -1,6 +1,8 @@ +pub mod path; pub mod zip; // File system related utility functions +#[allow(dead_code)] pub mod file_utils { use std::fs; use std::io::{self, Write}; @@ -16,6 +18,7 @@ pub mod file_utils { } // Configuration parsing utilities +#[allow(dead_code)] pub mod config_parser { use std::collections::HashMap; diff --git a/src-tauri/src/utils/path.rs b/src-tauri/src/utils/path.rs new file mode 100644 index 0000000..ab14c12 --- /dev/null +++ b/src-tauri/src/utils/path.rs @@ -0,0 +1,247 @@ +/// Path utilities for cross-platform compatibility +use std::path::PathBuf; + +/// Helper to strip UNC prefix on Windows (\\?\) +/// This is needed because std::fs::canonicalize adds UNC prefix on Windows +#[cfg(target_os = "windows")] +fn strip_unc_prefix(path: PathBuf) -> PathBuf { + let s = path.to_string_lossy().to_string(); + if s.starts_with(r"\\?\") { + return PathBuf::from(&s[4..]); + } + path +} + +#[cfg(not(target_os = "windows"))] +fn strip_unc_prefix(path: PathBuf) -> PathBuf { + path +} + +/// Normalize a Java executable path for the current platform. +/// +/// This function handles platform-specific requirements and validates that +/// the resulting path points to an executable Java binary. +/// +/// On Windows: +/// - Adds .exe extension if missing +/// - Attempts to locate java.exe in PATH if only "java" is provided +/// - Resolves symlinks and strips UNC prefix +/// - Validates that the path exists +/// +/// On Unix: +/// - Attempts to locate java in PATH using `which` if only "java" is provided +/// - Resolves symlinks to get canonical path +/// - Validates that the path exists +/// +/// # Arguments +/// * `java_path` - The Java executable path to normalize (can be relative, absolute, or "java") +/// +/// # Returns +/// * `Ok(PathBuf)` - Canonicalized, validated path to Java executable +/// * `Err(String)` - Error if the path cannot be found or validated +#[cfg(target_os = "windows")] +pub fn normalize_java_path(java_path: &str) -> Result<PathBuf, String> { + let mut path = PathBuf::from(java_path); + + // If path doesn't exist and doesn't end with .exe, try adding .exe + if !path.exists() && path.extension().is_none() { + path.set_extension("exe"); + } + + // If still not found and it's just "java.exe" (not an absolute path), try to find it in PATH + // Only search PATH for relative paths or just "java", not for absolute paths that don't exist + if !path.exists() + && !path.is_absolute() + && path.file_name() == Some(std::ffi::OsStr::new("java.exe")) + { + // Try to locate java.exe in PATH + if let Ok(output) = std::process::Command::new("where").arg("java").output() { + if output.status.success() { + let paths = String::from_utf8_lossy(&output.stdout); + if let Some(first_path) = paths.lines().next() { + path = PathBuf::from(first_path.trim()); + } + } + } + + // If still not found after PATH search, return specific error + if !path.exists() { + return Err( + "Java not found in PATH. Please install Java or configure the full path in Settings." + .to_string(), + ); + } + } + + // Verify the path exists before canonicalization + if !path.exists() { + return Err(format!( + "Java executable not found at: {}\nPlease configure a valid Java path in Settings.", + path.display() + )); + } + + // Canonicalize and strip UNC prefix for clean path + let canonical = std::fs::canonicalize(&path) + .map_err(|e| format!("Failed to resolve Java path '{}': {}", path.display(), e))?; + + Ok(strip_unc_prefix(canonical)) +} + +#[cfg(not(target_os = "windows"))] +pub fn normalize_java_path(java_path: &str) -> Result<PathBuf, String> { + let mut path = PathBuf::from(java_path); + + // If path doesn't exist and it's just "java", try to find java in PATH + if !path.exists() && java_path == "java" { + if let Ok(output) = std::process::Command::new("which").arg("java").output() { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout); + if let Some(first_path) = path_str.lines().next() { + path = PathBuf::from(first_path.trim()); + } + } + } + + // If still not found after PATH search, return specific error + if !path.exists() { + return Err( + "Java not found in PATH. Please install Java or configure the full path in Settings." + .to_string(), + ); + } + } + + // Verify the path exists before canonicalization + if !path.exists() { + return Err(format!( + "Java executable not found at: {}\nPlease configure a valid Java path in Settings.", + path.display() + )); + } + + // Canonicalize to resolve symlinks and get absolute path + let canonical = std::fs::canonicalize(&path) + .map_err(|e| format!("Failed to resolve Java path '{}': {}", path.display(), e))?; + + Ok(strip_unc_prefix(canonical)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Write; + + #[test] + #[cfg(target_os = "windows")] + fn test_normalize_nonexistent_path_windows() { + // Non-existent path should return error + let result = normalize_java_path("C:\\NonExistent\\Path\\java.exe"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn test_normalize_nonexistent_path_unix() { + // Non-existent path should return error + let result = normalize_java_path("/nonexistent/path/java"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } + + #[test] + #[cfg(target_os = "windows")] + fn test_normalize_adds_exe_extension() { + // This test assumes java is not in the current directory + let result = normalize_java_path("nonexistent_java"); + // Should fail since the file doesn't exist + assert!(result.is_err()); + } + + #[test] + fn test_normalize_existing_path_returns_canonical() { + // Test with a path that should exist on most systems + #[cfg(target_os = "windows")] + let test_path = "C:\\Windows\\System32\\cmd.exe"; + #[cfg(not(target_os = "windows"))] + let test_path = "/bin/sh"; + + if std::path::Path::new(test_path).exists() { + let result = normalize_java_path(test_path); + assert!(result.is_ok()); + let normalized = result.unwrap(); + // Should be absolute path after canonicalization + assert!(normalized.is_absolute()); + // Should not contain UNC prefix on Windows + #[cfg(target_os = "windows")] + assert!(!normalized.to_string_lossy().starts_with(r"\\?\")); + } + } + + #[test] + fn test_normalize_java_not_in_path() { + // When "java" is provided but not in PATH, should return error + // This test may pass if java IS in PATH, so we check error message format + let result = normalize_java_path("java"); + if result.is_err() { + let err = result.unwrap_err(); + assert!( + err.contains("not found in PATH") || err.contains("not found at"), + "Expected PATH error, got: {}", + err + ); + } + // If Ok, java was found in PATH - test passes + } + + #[test] + fn test_normalize_with_temp_file() { + // Create a temporary file to test with an actual existing path + let temp_dir = std::env::temp_dir(); + + #[cfg(target_os = "windows")] + let temp_file = temp_dir.join("test_java_normalize.exe"); + #[cfg(not(target_os = "windows"))] + let temp_file = temp_dir.join("test_java_normalize"); + + // Create the file + if let Ok(mut file) = fs::File::create(&temp_file) { + let _ = file.write_all(b"#!/bin/sh\necho test"); + drop(file); + + // Test normalization + let result = normalize_java_path(temp_file.to_str().unwrap()); + + // Clean up + let _ = fs::remove_file(&temp_file); + + // Verify result + assert!(result.is_ok(), "Failed to normalize temp file path"); + let normalized = result.unwrap(); + assert!(normalized.is_absolute()); + } + } + + #[test] + fn test_strip_unc_prefix() { + #[cfg(target_os = "windows")] + { + let unc_path = PathBuf::from(r"\\?\C:\Windows\System32\cmd.exe"); + let stripped = strip_unc_prefix(unc_path); + assert_eq!(stripped.to_string_lossy(), r"C:\Windows\System32\cmd.exe"); + + let normal_path = PathBuf::from(r"C:\Windows\System32\cmd.exe"); + let unchanged = strip_unc_prefix(normal_path.clone()); + assert_eq!(unchanged, normal_path); + } + + #[cfg(not(target_os = "windows"))] + { + let path = PathBuf::from("/usr/bin/java"); + let unchanged = strip_unc_prefix(path.clone()); + assert_eq!(unchanged, path); + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 060a871..dd84fd4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "dropout", - "version": "0.1.23", + "version": "0.1.26", "identifier": "com.dropout.launcher", "build": { "beforeDevCommand": "pnpm -C ../ui dev", @@ -20,7 +20,7 @@ } ], "security": { - "csp": null, + "csp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:;", "capabilities": ["default"] } }, diff --git a/ui/package.json b/ui/package.json index 82f8db3..008fcfb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,18 +14,20 @@ }, "dependencies": { "@tauri-apps/api": "^2.9.1", - "@tauri-apps/plugin-dialog": "^2.5.0", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-shell": "^2.3.4", "lucide-svelte": "^0.562.0", "marked": "^17.0.1", - "node-emoji": "^2.2.0" + "node-emoji": "^2.2.0", + "prismjs": "^1.30.0" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/vite": "^4.1.18", "@tsconfig/svelte": "^5.0.6", "@types/node": "^24.10.1", + "@types/prismjs": "^1.26.5", "autoprefixer": "^10.4.23", "oxfmt": "^0.24.0", "oxlint": "^1.39.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 390862c..465b682 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^2.9.1 version: 2.9.1 '@tauri-apps/plugin-dialog': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.6.0 + version: 2.6.0 '@tauri-apps/plugin-fs': specifier: ^2.4.5 version: 2.4.5 @@ -32,6 +32,9 @@ importers: node-emoji: specifier: ^2.2.0 version: 2.2.0 + prismjs: + specifier: ^1.30.0 + version: 1.30.0 devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^6.2.1 @@ -45,6 +48,9 @@ importers: '@types/node': specifier: ^24.10.1 version: 24.10.7 + '@types/prismjs': + specifier: ^1.26.5 + version: 1.26.5 autoprefixer: specifier: ^10.4.23 version: 10.4.23(postcss@8.5.6) @@ -124,21 +130,25 @@ packages: resolution: {integrity: sha512-ItPDOPoQ0wLj/s8osc5ch57uUcA1Wk8r0YdO8vLRpXA3UNg7KPOm1vdbkIZRRiSUphZcuX5ioOEetEK8H7RlTw==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/linux-arm64-musl@0.24.0': resolution: {integrity: sha512-JkQO3WnQjQTJONx8nxdgVBfl6BBFfpp9bKhChYhWeakwJdr7QPOAWJ/v3FGZfr0TbqINwnNR74aVZayDDRyXEA==} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/linux-x64-gnu@0.24.0': resolution: {integrity: sha512-N/SXlFO+2kak5gMt0oxApi0WXQDhwA0PShR0UbkY0PwtHjfSiDqJSOumyNqgQVoroKr1GNnoRmUqjZIz6DKIcw==} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/linux-x64-musl@0.24.0': resolution: {integrity: sha512-WM0pek5YDCQf50XQ7GLCE9sMBCMPW/NPAEPH/Hx6Qyir37lEsP4rUmSECo/QFNTU6KBc9NnsviAyJruWPpCMXw==} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/win32-arm64@0.24.0': resolution: {integrity: sha512-vFCseli1KWtwdHrVlT/jWfZ8jP8oYpnPPEjI23mPLW8K/6GEJmmvy0PZP5NpWUFNTzX0lqie58XnrATJYAe9Xw==} @@ -164,21 +174,25 @@ packages: resolution: {integrity: sha512-qocBkvS2V6rH0t9AT3DfQunMnj3xkM7srs5/Ycj2j5ZqMoaWd/FxHNVJDFP++35roKSvsRJoS0mtA8/77jqm6Q==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/linux-arm64-musl@1.39.0': resolution: {integrity: sha512-arZzAc1PPcz9epvGBBCMHICeyQloKtHX3eoOe62B3Dskn7gf6Q14wnDHr1r9Vp4vtcBATNq6HlKV14smdlC/qA==} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/linux-x64-gnu@1.39.0': resolution: {integrity: sha512-ZVt5qsECpuNprdWxAPpDBwoixr1VTcZ4qAEQA2l/wmFyVPDYFD3oBY/SWACNnWBddMrswjTg9O8ALxYWoEpmXw==} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/linux-x64-musl@1.39.0': resolution: {integrity: sha512-pB0hlGyKPbxr9NMIV783lD6cWL3MpaqnZRM9MWni4yBdHPTKyFNYdg5hGD0Bwg+UP4S2rOevq/+OO9x9Bi7E6g==} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/win32-arm64@1.39.0': resolution: {integrity: sha512-Gg2SFaJohI9+tIQVKXlPw3FsPQFi/eCSWiCgwPtPn5uzQxHRTeQEZKuluz1fuzR5U70TXubb2liZi4Dgl8LJQA==} @@ -225,24 +239,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} @@ -338,24 +356,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -393,8 +415,8 @@ packages: '@tauri-apps/api@2.9.1': resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==} - '@tauri-apps/plugin-dialog@2.5.0': - resolution: {integrity: sha512-I0R0ygwRd9AN8Wj5GnzCogOlqu2+OWAtBd0zEC4+kQCI32fRowIyuhPCBoUv4h/lQt2bM39kHlxPHD5vDcFjiA==} + '@tauri-apps/plugin-dialog@2.6.0': + resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} '@tauri-apps/plugin-fs@2.4.5': resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==} @@ -414,6 +436,9 @@ packages: '@types/node@24.10.7': resolution: {integrity: sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==} + '@types/prismjs@1.26.5': + resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -551,24 +576,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -650,6 +679,10 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -1005,7 +1038,7 @@ snapshots: '@tauri-apps/api@2.9.1': {} - '@tauri-apps/plugin-dialog@2.5.0': + '@tauri-apps/plugin-dialog@2.6.0': dependencies: '@tauri-apps/api': 2.9.1 @@ -1030,6 +1063,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/prismjs@1.26.5': {} + acorn@8.15.0: {} aria-query@5.3.2: {} @@ -1217,6 +1252,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prismjs@1.30.0: {} + readdirp@4.1.2: {} rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1): diff --git a/ui/public/vite.svg b/ui/public/vite.svg index e7b8dfb..ee9fada 100644 --- a/ui/public/vite.svg +++ b/ui/public/vite.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 760a15f..127bbea 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -10,14 +10,18 @@ import LoginModal from "./components/LoginModal.svelte"; import ParticleBackground from "./components/ParticleBackground.svelte"; import SettingsView from "./components/SettingsView.svelte"; + import AssistantView from "./components/AssistantView.svelte"; + import InstancesView from "./components/InstancesView.svelte"; import Sidebar from "./components/Sidebar.svelte"; import StatusToast from "./components/StatusToast.svelte"; import VersionsView from "./components/VersionsView.svelte"; // Stores import { authState } from "./stores/auth.svelte"; import { gameState } from "./stores/game.svelte"; + import { instancesState } from "./stores/instances.svelte"; import { settingsState } from "./stores/settings.svelte"; import { uiState } from "./stores/ui.svelte"; + import { logsState } from "./stores/logs.svelte"; import { convertFileSrc } from "@tauri-apps/api/core"; let mouseX = $state(0); @@ -29,24 +33,20 @@ } onMount(async () => { + // ENFORCE DARK MODE: Always add 'dark' class and attribute + document.documentElement.classList.add('dark'); + document.documentElement.setAttribute('data-theme', 'dark'); + document.documentElement.classList.remove('light'); + authState.checkAccount(); await settingsState.loadSettings(); + logsState.init(); await settingsState.detectJava(); + await instancesState.loadInstances(); gameState.loadVersions(); getVersion().then((v) => (uiState.appVersion = v)); window.addEventListener("mousemove", handleMouseMove); }); - - $effect(() => { - // ENFORCE DARK MODE: Always add 'dark' class and attribute - // This combined with the @variant dark in app.css ensures dark mode is always active - // regardless of system preference settings. - document.documentElement.classList.add('dark'); - document.documentElement.setAttribute('data-theme', 'dark'); - - // Ensure 'light' class is never present - document.documentElement.classList.remove('light'); - }); onDestroy(() => { if (typeof window !== 'undefined') @@ -116,10 +116,14 @@ <div class="flex-1 relative overflow-hidden"> {#if uiState.currentView === "home"} <HomeView mouseX={mouseX} mouseY={mouseY} /> + {:else if uiState.currentView === "instances"} + <InstancesView /> {:else if uiState.currentView === "versions"} <VersionsView /> {:else if uiState.currentView === "settings"} <SettingsView /> + {:else if uiState.currentView === "guide"} + <AssistantView /> {/if} </div> diff --git a/ui/src/assets/svelte.svg b/ui/src/assets/svelte.svg index c5e0848..8c056ce 100644 --- a/ui/src/assets/svelte.svg +++ b/ui/src/assets/svelte.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg> diff --git a/ui/src/components/AssistantView.svelte b/ui/src/components/AssistantView.svelte new file mode 100644 index 0000000..54509a5 --- /dev/null +++ b/ui/src/components/AssistantView.svelte @@ -0,0 +1,436 @@ +<script lang="ts"> + import { assistantState } from '../stores/assistant.svelte'; + import { settingsState } from '../stores/settings.svelte'; + import { Send, Bot, RefreshCw, Trash2, AlertTriangle, Settings, Brain, ChevronDown } from 'lucide-svelte'; + import { uiState } from '../stores/ui.svelte'; + import { marked } from 'marked'; + import { onMount } from 'svelte'; + + let input = $state(''); + let messagesContainer: HTMLDivElement | undefined = undefined; + + function parseMessageContent(content: string) { + if (!content) return { thinking: null, content: '', isThinking: false }; + + // Support both <thinking> and <think> (DeepSeek uses <think>) + let startTag = '<thinking>'; + let endTag = '</thinking>'; + let startIndex = content.indexOf(startTag); + + if (startIndex === -1) { + startTag = '<think>'; + endTag = '</think>'; + startIndex = content.indexOf(startTag); + } + + // Also check for encoded tags if they weren't decoded properly + if (startIndex === -1) { + startTag = '\u003cthink\u003e'; + endTag = '\u003c/think\u003e'; + startIndex = content.indexOf(startTag); + } + + if (startIndex !== -1) { + const endIndex = content.indexOf(endTag, startIndex); + + if (endIndex !== -1) { + // Completed thinking block + // We extract the thinking part and keep the rest (before and after) + const before = content.substring(0, startIndex); + const thinking = content.substring(startIndex + startTag.length, endIndex).trim(); + const after = content.substring(endIndex + endTag.length); + + return { + thinking, + content: (before + after).trim(), + isThinking: false + }; + } else { + // Incomplete thinking block (still streaming) + const before = content.substring(0, startIndex); + const thinking = content.substring(startIndex + startTag.length).trim(); + + return { + thinking, + content: before.trim(), + isThinking: true + }; + } + } + + return { thinking: null, content, isThinking: false }; + } + + function renderMarkdown(content: string): string { + if (!content) return ''; + try { + // marked.parse returns string synchronously when async is false (default) + return marked(content, { breaks: true, gfm: true }) as string; + } catch { + return content; + } + } + + function scrollToBottom() { + if (messagesContainer) { + setTimeout(() => { + if (messagesContainer) { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + }, 0); + } + } + + onMount(() => { + assistantState.init(); + }); + + // Scroll to bottom when messages change + $effect(() => { + // Access reactive state + const _len = assistantState.messages.length; + const _processing = assistantState.isProcessing; + // Scroll on next tick + if (_len > 0 || _processing) { + scrollToBottom(); + } + }); + + async function handleSubmit() { + if (!input.trim() || assistantState.isProcessing) return; + const text = input; + input = ''; + const provider = settingsState.settings.assistant.llm_provider; + const endpoint = provider === 'ollama' + ? settingsState.settings.assistant.ollama_endpoint + : settingsState.settings.assistant.openai_endpoint; + await assistantState.sendMessage( + text, + settingsState.settings.assistant.enabled, + provider, + endpoint + ); + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + } + + function getProviderName(): string { + const provider = settingsState.settings.assistant.llm_provider; + if (provider === 'ollama') { + return `Ollama (${settingsState.settings.assistant.ollama_model})`; + } else if (provider === 'openai') { + return `OpenAI (${settingsState.settings.assistant.openai_model})`; + } + return provider; + } + + function getProviderHelpText(): string { + const provider = settingsState.settings.assistant.llm_provider; + if (provider === 'ollama') { + return `Please ensure Ollama is installed and running at ${settingsState.settings.assistant.ollama_endpoint}.`; + } else if (provider === 'openai') { + return "Please check your OpenAI API key in Settings > AI Assistant."; + } + return ""; + } +</script> + +<div class="h-full w-full flex flex-col gap-4 p-4 lg:p-8 animate-in fade-in zoom-in-95 duration-300"> + <div class="flex items-center justify-between mb-2"> + <div class="flex items-center gap-3"> + <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400"> + <Bot size={24} /> + </div> + <div> + <h2 class="text-2xl font-bold">Game Assistant</h2> + <p class="text-zinc-400 text-sm">Powered by {getProviderName()}</p> + </div> + </div> + + <div class="flex items-center gap-2"> + {#if !settingsState.settings.assistant.enabled} + <div class="flex items-center gap-2 px-3 py-1.5 bg-zinc-500/10 text-zinc-400 rounded-full text-xs font-medium border border-zinc-500/20"> + <AlertTriangle size={14} /> + <span>Disabled</span> + </div> + {:else if !assistantState.isProviderHealthy} + <div class="flex items-center gap-2 px-3 py-1.5 bg-red-500/10 text-red-400 rounded-full text-xs font-medium border border-red-500/20"> + <AlertTriangle size={14} /> + <span>Offline</span> + </div> + {:else} + <div class="flex items-center gap-2 px-3 py-1.5 bg-emerald-500/10 text-emerald-400 rounded-full text-xs font-medium border border-emerald-500/20"> + <div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div> + <span>Online</span> + </div> + {/if} + + <button + onclick={() => assistantState.checkHealth()} + class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors" + title="Check Connection" + > + <RefreshCw size={18} class={assistantState.isProcessing ? "animate-spin" : ""} /> + </button> + + <button + onclick={() => assistantState.clearHistory()} + class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors" + title="Clear History" + > + <Trash2 size={18} /> + </button> + + <button + onclick={() => uiState.setView('settings')} + class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors" + title="Settings" + > + <Settings size={18} /> + </button> + </div> + </div> + + <!-- Chat Area --> + <div class="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative"> + {#if assistantState.messages.length === 0} + <div class="absolute inset-0 flex flex-col items-center justify-center text-zinc-500 gap-4 p-8 text-center"> + <Bot size={48} class="opacity-20" /> + <div class="max-w-md"> + <p class="text-lg font-medium text-zinc-300 mb-2">How can I help you today?</p> + <p class="text-sm opacity-70">I can analyze your game logs, diagnose crashes, or explain mod features.</p> + </div> + {#if !settingsState.settings.assistant.enabled} + <div class="bg-zinc-500/10 border border-zinc-500/20 rounded-lg p-4 text-sm text-zinc-400 mt-4 max-w-sm"> + Assistant is disabled. Enable it in <button onclick={() => uiState.setView('settings')} class="text-indigo-400 hover:underline">Settings > AI Assistant</button>. + </div> + {:else if !assistantState.isProviderHealthy} + <div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-sm text-red-400 mt-4 max-w-sm"> + {getProviderHelpText()} + </div> + {/if} + </div> + {/if} + + <div + bind:this={messagesContainer} + class="flex-1 overflow-y-auto p-4 space-y-4 scroll-smooth" + > + {#each assistantState.messages as msg, idx} + <div class="flex gap-3 {msg.role === 'user' ? 'justify-end' : 'justify-start'}"> + {#if msg.role === 'assistant'} + <div class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-400 shrink-0 mt-1"> + <Bot size={16} /> + </div> + {/if} + + <div class="max-w-[80%] p-3 rounded-2xl {msg.role === 'user' ? 'bg-indigo-600 text-white rounded-tr-none' : 'bg-zinc-800/50 text-zinc-200 rounded-tl-none border border-white/5'}"> + {#if msg.role === 'user'} + <div class="break-words whitespace-pre-wrap"> + {msg.content} + </div> + {:else} + {@const parsed = parseMessageContent(msg.content)} + + <!-- Thinking Block --> + {#if parsed.thinking} + <div class="mb-3 max-w-full overflow-hidden"> + <details class="group" open={parsed.isThinking}> + <summary class="list-none cursor-pointer flex items-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors text-xs font-medium select-none bg-black/20 p-2 rounded-lg border border-white/5 w-fit mb-2 outline-none"> + <Brain size={14} /> + <span>Thinking Process</span> + <ChevronDown size={14} class="transition-transform duration-200 group-open:rotate-180" /> + </summary> + <div class="pl-3 border-l-2 border-zinc-700 text-zinc-500 text-xs italic leading-relaxed whitespace-pre-wrap font-mono max-h-96 overflow-y-auto custom-scrollbar bg-black/10 p-2 rounded-r-md"> + {parsed.thinking} + {#if parsed.isThinking} + <span class="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle"></span> + {/if} + </div> + </details> + </div> + {/if} + + <!-- Markdown rendered content for assistant --> + <div class="markdown-content prose prose-invert prose-sm max-w-none"> + {#if parsed.content} + {@html renderMarkdown(parsed.content)} + {:else if assistantState.isProcessing && idx === assistantState.messages.length - 1 && !parsed.isThinking} + <span class="inline-flex items-center gap-1"> + <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse"></span> + <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.2s"></span> + <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.4s"></span> + </span> + {/if} + </div> + + <!-- Generation Stats --> + {#if msg.stats} + <div class="mt-3 pt-3 border-t border-white/5 text-[10px] text-zinc-500 font-mono flex flex-wrap gap-x-4 gap-y-1 opacity-70 hover:opacity-100 transition-opacity select-none"> + <div class="flex gap-1" title="Tokens generated"> + <span>Eval:</span> + <span class="text-zinc-400">{msg.stats.eval_count} tokens</span> + </div> + <div class="flex gap-1" title="Total duration"> + <span>Time:</span> + <span class="text-zinc-400">{(msg.stats.total_duration / 1e9).toFixed(2)}s</span> + </div> + {#if msg.stats.eval_duration > 0} + <div class="flex gap-1" title="Generation speed"> + <span>Speed:</span> + <span class="text-zinc-400">{(msg.stats.eval_count / (msg.stats.eval_duration / 1e9)).toFixed(1)} t/s</span> + </div> + {/if} + </div> + {/if} + {/if} + </div> + </div> + {/each} + </div> + + <!-- Input Area --> + <div class="p-4 bg-zinc-900/50 border-t border-white/5"> + <div class="relative"> + <textarea + bind:value={input} + onkeydown={handleKeydown} + placeholder={settingsState.settings.assistant.enabled ? "Ask about your game..." : "Assistant is disabled..."} + class="w-full bg-black/20 border border-white/10 rounded-xl py-3 pl-4 pr-12 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 resize-none h-[52px] max-h-32 transition-all text-white disabled:opacity-50" + disabled={assistantState.isProcessing || !settingsState.settings.assistant.enabled} + ></textarea> + + <button + onclick={handleSubmit} + disabled={!input.trim() || assistantState.isProcessing || !settingsState.settings.assistant.enabled} + class="absolute right-2 top-2 p-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:hover:bg-indigo-600 text-white rounded-lg transition-colors" + > + <Send size={16} /> + </button> + </div> + </div> + </div> +</div> + +<style> + /* Markdown content styles */ + .markdown-content :global(p) { + margin-bottom: 0.5rem; + } + + .markdown-content :global(p:last-child) { + margin-bottom: 0; + } + + .markdown-content :global(pre) { + background-color: rgba(0, 0, 0, 0.4); + border-radius: 0.5rem; + padding: 0.75rem; + overflow-x: auto; + margin: 0.5rem 0; + } + + .markdown-content :global(code) { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + font-size: 0.85em; + } + + .markdown-content :global(pre code) { + background: none; + padding: 0; + } + + .markdown-content :global(:not(pre) > code) { + background-color: rgba(0, 0, 0, 0.3); + padding: 0.15rem 0.4rem; + border-radius: 0.25rem; + } + + .markdown-content :global(ul), + .markdown-content :global(ol) { + margin: 0.5rem 0; + padding-left: 1.5rem; + } + + .markdown-content :global(li) { + margin: 0.25rem 0; + } + + .markdown-content :global(blockquote) { + border-left: 3px solid rgba(99, 102, 241, 0.5); + padding-left: 1rem; + margin: 0.5rem 0; + color: rgba(255, 255, 255, 0.7); + } + + .markdown-content :global(h1), + .markdown-content :global(h2), + .markdown-content :global(h3), + .markdown-content :global(h4) { + font-weight: 600; + margin: 0.75rem 0 0.5rem 0; + } + + .markdown-content :global(h1) { + font-size: 1.25rem; + } + + .markdown-content :global(h2) { + font-size: 1.125rem; + } + + .markdown-content :global(h3) { + font-size: 1rem; + } + + .markdown-content :global(a) { + color: rgb(129, 140, 248); + text-decoration: underline; + } + + .markdown-content :global(a:hover) { + color: rgb(165, 180, 252); + } + + .markdown-content :global(table) { + border-collapse: collapse; + margin: 0.5rem 0; + width: 100%; + } + + .markdown-content :global(th), + .markdown-content :global(td) { + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 0.5rem; + text-align: left; + } + + .markdown-content :global(th) { + background-color: rgba(0, 0, 0, 0.3); + font-weight: 600; + } + + .markdown-content :global(hr) { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin: 1rem 0; + } + + .markdown-content :global(img) { + max-width: 100%; + border-radius: 0.5rem; + } + + .markdown-content :global(strong) { + font-weight: 600; + } + + .markdown-content :global(em) { + font-style: italic; + } +</style> diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte index b7bbf71..19cf35d 100644 --- a/ui/src/components/BottomBar.svelte +++ b/ui/src/components/BottomBar.svelte @@ -4,7 +4,8 @@ import { authState } from "../stores/auth.svelte"; import { gameState } from "../stores/game.svelte"; import { uiState } from "../stores/ui.svelte"; - import { Terminal, ChevronDown, Play, User, Check, RefreshCw } from 'lucide-svelte'; + import { instancesState } from "../stores/instances.svelte"; + import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte'; interface InstalledVersion { id: string; @@ -16,29 +17,44 @@ let installedVersions = $state<InstalledVersion[]>([]); let isLoadingVersions = $state(true); let downloadCompleteUnlisten: UnlistenFn | null = null; + let versionDeletedUnlisten: UnlistenFn | null = null; // Load installed versions on mount $effect(() => { loadInstalledVersions(); - setupDownloadListener(); + setupEventListeners(); return () => { if (downloadCompleteUnlisten) { downloadCompleteUnlisten(); } + if (versionDeletedUnlisten) { + versionDeletedUnlisten(); + } }; }); - async function setupDownloadListener() { + async function setupEventListeners() { // Refresh list when a download completes downloadCompleteUnlisten = await listen("download-complete", () => { loadInstalledVersions(); }); + // Refresh list when a version is deleted + versionDeletedUnlisten = await listen("version-deleted", () => { + loadInstalledVersions(); + }); } async function loadInstalledVersions() { + if (!instancesState.activeInstanceId) { + installedVersions = []; + isLoadingVersions = false; + return; + } isLoadingVersions = true; try { - installedVersions = await invoke<InstalledVersion[]>("list_installed_versions"); + installedVersions = await invoke<InstalledVersion[]>("list_installed_versions", { + instanceId: instancesState.activeInstanceId, + }); // If no version is selected but we have installed versions, select the first one if (!gameState.selectedVersion && installedVersions.length > 0) { gameState.selectedVersion = installedVersions[0].id; @@ -160,18 +176,7 @@ <div class="flex flex-col items-end mr-2"> <!-- Custom Version Dropdown --> <div class="relative" bind:this={dropdownRef}> - <div class="flex items-center gap-2"> - <button - type="button" - onclick={() => loadInstalledVersions()} - class="p-2.5 dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md - dark:text-zinc-500 text-zinc-400 dark:hover:text-white hover:text-black - dark:hover:border-zinc-600 hover:border-zinc-400 transition-colors" - title="Refresh installed versions" - > - <RefreshCw size={14} class={isLoadingVersions ? 'animate-spin' : ''} /> - </button> - <button + <button type="button" onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen} disabled={installedVersions.length === 0 && !isLoadingVersions} @@ -183,21 +188,20 @@ transition-colors cursor-pointer outline-none disabled:opacity-50 disabled:cursor-not-allowed" > - <span class="truncate"> - {#if isLoadingVersions} - Loading... - {:else if installedVersions.length === 0} - No versions installed - {:else} - {gameState.selectedVersion || "Select version"} - {/if} - </span> - <ChevronDown - size={14} - class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}" - /> - </button> - </div> + <span class="truncate"> + {#if isLoadingVersions} + Loading... + {:else if installedVersions.length === 0} + No versions installed + {:else} + {gameState.selectedVersion || "Select version"} + {/if} + </span> + <ChevronDown + size={14} + class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}" + /> + </button> {#if isVersionDropdownOpen && installedVersions.length > 0} <div diff --git a/ui/src/components/ConfigEditorModal.svelte b/ui/src/components/ConfigEditorModal.svelte new file mode 100644 index 0000000..dd866ee --- /dev/null +++ b/ui/src/components/ConfigEditorModal.svelte @@ -0,0 +1,369 @@ +<script lang="ts"> + import { settingsState } from "../stores/settings.svelte"; + import { Save, X, FileJson, AlertCircle, Undo, Redo, Settings } from "lucide-svelte"; + import Prism from 'prismjs'; + import 'prismjs/components/prism-json'; + import 'prismjs/themes/prism-tomorrow.css'; + + let content = $state(settingsState.rawConfigContent); + let isSaving = $state(false); + let localError = $state(""); + + let textareaRef: HTMLTextAreaElement | undefined = $state(); + let preRef: HTMLPreElement | undefined = $state(); + let lineNumbersRef: HTMLDivElement | undefined = $state(); + + // Textarea attributes that TypeScript doesn't recognize but are valid HTML + const textareaAttrs = { + autocorrect: "off", + autocapitalize: "off" + } as Record<string, string>; + + // History State + let history = $state([settingsState.rawConfigContent]); + let historyIndex = $state(0); + let debounceTimer: ReturnType<typeof setTimeout> | undefined; + + // Editor Settings + let showLineNumbers = $state(localStorage.getItem('editor_showLineNumbers') !== 'false'); + let showStatusBar = $state(localStorage.getItem('editor_showStatusBar') !== 'false'); + let showSettings = $state(false); + + // Cursor Status + let cursorLine = $state(1); + let cursorCol = $state(1); + + let lines = $derived(content.split('\n')); + + $effect(() => { + localStorage.setItem('editor_showLineNumbers', String(showLineNumbers)); + localStorage.setItem('editor_showStatusBar', String(showStatusBar)); + }); + + // Cleanup timer on destroy + $effect(() => { + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + }; + }); + + // Initial validation + $effect(() => { + validate(content); + }); + + function validate(text: string) { + try { + JSON.parse(text); + localError = ""; + } catch (e: any) { + localError = e.message; + } + } + + function pushHistory(newContent: string, immediate = false) { + if (debounceTimer) clearTimeout(debounceTimer); + + const commit = () => { + if (newContent === history[historyIndex]) return; + const next = history.slice(0, historyIndex + 1); + next.push(newContent); + history = next; + historyIndex = next.length - 1; + }; + + if (immediate) { + commit(); + } else { + debounceTimer = setTimeout(commit, 500); + } + } + + function handleUndo() { + if (historyIndex > 0) { + historyIndex--; + content = history[historyIndex]; + validate(content); + } + } + + function handleRedo() { + if (historyIndex < history.length - 1) { + historyIndex++; + content = history[historyIndex]; + validate(content); + } + } + + function updateCursor() { + if (!textareaRef) return; + const pos = textareaRef.selectionStart; + const text = textareaRef.value.substring(0, pos); + const lines = text.split('\n'); + cursorLine = lines.length; + cursorCol = lines[lines.length - 1].length + 1; + } + + function handleInput(e: Event) { + const target = e.target as HTMLTextAreaElement; + content = target.value; + validate(content); + pushHistory(content); + updateCursor(); + } + + function handleScroll() { + if (textareaRef) { + if (preRef) { + preRef.scrollTop = textareaRef.scrollTop; + preRef.scrollLeft = textareaRef.scrollLeft; + } + if (lineNumbersRef) { + lineNumbersRef.scrollTop = textareaRef.scrollTop; + } + } + } + + let highlightedCode = $derived( + Prism.highlight(content, Prism.languages.json, 'json') + '\n' + ); + + async function handleSave(close = false) { + if (localError) return; + isSaving = true; + await settingsState.saveRawConfig(content, close); + isSaving = false; + } + + function handleKeydown(e: KeyboardEvent) { + // Save + if (e.key === 's' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSave(false); // Keep open on shortcut save + } + // Undo + else if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) { + e.preventDefault(); + handleUndo(); + } + // Redo (Ctrl+Shift+Z or Ctrl+Y) + else if ( + (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey) || + (e.key === 'y' && (e.ctrlKey || e.metaKey)) + ) { + e.preventDefault(); + handleRedo(); + } + // Close + else if (e.key === 'Escape') { + settingsState.closeConfigEditor(); + } + // Tab + else if (e.key === 'Tab') { + e.preventDefault(); + const target = e.target as HTMLTextAreaElement; + const start = target.selectionStart; + const end = target.selectionEnd; + + pushHistory(content, true); + + const newContent = content.substring(0, start) + " " + content.substring(end); + content = newContent; + + pushHistory(content, true); + + setTimeout(() => { + target.selectionStart = target.selectionEnd = start + 2; + updateCursor(); + }, 0); + validate(content); + } + } +</script> + +<div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70 animate-in fade-in duration-200"> + <div + class="bg-[#1d1f21] rounded-xl border border-zinc-700 shadow-2xl w-[900px] max-w-[95vw] h-[85vh] flex flex-col overflow-hidden" + role="dialog" + aria-modal="true" + > + <!-- Header --> + <div class="flex items-center justify-between p-4 border-b border-zinc-700 bg-[#1d1f21] z-20 relative"> + <div class="flex items-center gap-3"> + <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400"> + <FileJson size={20} /> + </div> + <div class="flex flex-col"> + <h3 class="text-lg font-bold text-white leading-none">Configuration Editor</h3> + <span class="text-[10px] text-zinc-500 font-mono mt-1 break-all">{settingsState.configFilePath}</span> + </div> + </div> + <div class="flex items-center gap-2"> + <!-- Undo/Redo Buttons --> + <div class="flex items-center bg-zinc-800 rounded-lg p-0.5 mr-2 border border-zinc-700"> + <button + onclick={handleUndo} + disabled={historyIndex === 0} + class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors" + title="Undo (Ctrl+Z)" + > + <Undo size={16} /> + </button> + <button + onclick={handleRedo} + disabled={historyIndex === history.length - 1} + class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors" + title="Redo (Ctrl+Y)" + > + <Redo size={16} /> + </button> + </div> + + <!-- Settings Toggle --> + <div class="relative"> + <button + onclick={() => showSettings = !showSettings} + class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg {showSettings ? 'bg-white/10 text-white' : ''}" + title="Editor Settings" + > + <Settings size={20} /> + </button> + + {#if showSettings} + <div class="absolute right-0 top-full mt-2 w-48 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl p-2 z-50 flex flex-col gap-1"> + <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer"> + <input type="checkbox" bind:checked={showLineNumbers} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" /> + <span class="text-sm text-zinc-300">Line Numbers</span> + </label> + <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer"> + <input type="checkbox" bind:checked={showStatusBar} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" /> + <span class="text-sm text-zinc-300">Cursor Status</span> + </label> + </div> + {/if} + </div> + + <button + onclick={() => settingsState.closeConfigEditor()} + class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg" + title="Close (Esc)" + > + <X size={20} /> + </button> + </div> + </div> + + <!-- Error Banner --> + {#if localError || settingsState.configEditorError} + <div class="bg-red-500/10 border-b border-red-500/20 p-3 flex items-start gap-3 animate-in slide-in-from-top-2 z-10 relative"> + <AlertCircle size={16} class="text-red-400 mt-0.5 shrink-0" /> + <p class="text-xs text-red-300 font-mono whitespace-pre-wrap">{localError || settingsState.configEditorError}</p> + </div> + {/if} + + <!-- Editor Body (Flex row for line numbers + code) --> + <div class="flex-1 flex overflow-hidden relative bg-[#1d1f21]"> + <!-- Line Numbers --> + {#if showLineNumbers} + <div + bind:this={lineNumbersRef} + class="pt-4 pb-4 pr-3 pl-2 text-right text-zinc-600 bg-[#1d1f21] border-r border-zinc-700/50 font-mono select-none overflow-hidden min-w-[3rem]" + aria-hidden="true" + > + {#each lines as _, i} + <div class="leading-[20px] text-[13px]">{i + 1}</div> + {/each} + </div> + {/if} + + <!-- Code Area --> + <div class="flex-1 relative overflow-hidden group"> + <!-- Highlighted Code (Background) --> + <pre + bind:this={preRef} + aria-hidden="true" + class="absolute inset-0 w-full h-full p-4 m-0 bg-transparent pointer-events-none overflow-hidden whitespace-pre font-mono text-sm leading-relaxed" + ><code class="language-json">{@html highlightedCode}</code></pre> + + <!-- Textarea (Foreground) --> + <textarea + bind:this={textareaRef} + bind:value={content} + oninput={handleInput} + onkeydown={handleKeydown} + onscroll={handleScroll} + onmouseup={updateCursor} + onkeyup={updateCursor} + onclick={() => showSettings = false} + class="absolute inset-0 w-full h-full p-4 bg-transparent text-transparent caret-white font-mono text-sm leading-relaxed resize-none focus:outline-none whitespace-pre overflow-auto z-10 selection:bg-indigo-500/30" + spellcheck="false" + {...textareaAttrs} + ></textarea> + </div> + </div> + + <!-- Footer --> + <div class="p-3 border-t border-zinc-700 bg-[#1d1f21] flex justify-between items-center z-20 relative"> + <div class="text-xs text-zinc-500 flex gap-4 items-center"> + {#if showStatusBar} + <div class="flex gap-3 font-mono border-r border-zinc-700 pr-4 mr-1"> + <span>Ln {cursorLine}</span> + <span>Col {cursorCol}</span> + </div> + {/if} + <span class="hidden sm:inline"><span class="bg-white/10 px-1.5 py-0.5 rounded text-zinc-300">Ctrl+S</span> save</span> + </div> + <div class="flex gap-3"> + <button + onclick={() => settingsState.closeConfigEditor()} + class="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-medium transition-colors" + > + Cancel + </button> + <button + onclick={() => handleSave(false)} + disabled={isSaving || !!localError} + class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2" + title={localError ? "Fix errors before saving" : "Save changes"} + > + {#if isSaving} + <div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> + Saving... + {:else} + <Save size={16} /> + Save + {/if} + </button> + </div> + </div> + </div> +</div> + +<style> + /* Ensure exact font match */ + pre, textarea { + font-family: 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: 13px !important; + line-height: 20px !important; + letter-spacing: 0px !important; + tab-size: 2; + } + + /* Hide scrollbar for pre but keep it functional for textarea */ + pre::-webkit-scrollbar { + display: none; + } + + /* Override Prism background and font weights for alignment */ + :global(pre[class*="language-"]), :global(code[class*="language-"]) { + background: transparent !important; + text-shadow: none !important; + box-shadow: none !important; + } + + /* CRITICAL: Force normal weight to match textarea */ + :global(.token) { + font-weight: normal !important; + font-style: normal !important; + } +</style> diff --git a/ui/src/components/CustomSelect.svelte b/ui/src/components/CustomSelect.svelte index 2e89c75..0767471 100644 --- a/ui/src/components/CustomSelect.svelte +++ b/ui/src/components/CustomSelect.svelte @@ -13,6 +13,7 @@ placeholder?: string; disabled?: boolean; class?: string; + allowCustom?: boolean; // New prop to allow custom input onchange?: (value: string) => void; } @@ -22,17 +23,25 @@ placeholder = "Select...", disabled = false, class: className = "", + allowCustom = false, onchange }: Props = $props(); let isOpen = $state(false); let containerRef: HTMLDivElement; + let customInput = $state(""); // State for custom input let selectedOption = $derived(options.find(o => o.value === value)); + // Display label: if option exists use its label, otherwise if custom is allowed use raw value, else placeholder + let displayLabel = $derived(selectedOption ? selectedOption.label : (allowCustom && value ? value : placeholder)); function toggle() { if (!disabled) { isOpen = !isOpen; + // When opening, if current value is custom (not in options), pre-fill input + if (isOpen && allowCustom && !selectedOption) { + customInput = value; + } } } @@ -43,6 +52,13 @@ onchange?.(option.value); } + function handleCustomSubmit() { + if (!customInput.trim()) return; + value = customInput.trim(); + isOpen = false; + onchange?.(value); + } + function handleKeydown(e: KeyboardEvent) { if (disabled) return; @@ -98,8 +114,8 @@ transition-colors cursor-pointer outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-700" > - <span class="truncate {!selectedOption ? 'text-zinc-500' : ''}"> - {selectedOption?.label || placeholder} + <span class="truncate {(!selectedOption && !value) ? 'text-zinc-500' : ''}"> + {displayLabel} </span> <ChevronDown size={14} @@ -111,8 +127,29 @@ {#if isOpen} <div class="absolute z-50 w-full mt-1 py-1 bg-zinc-900 border border-zinc-700 rounded-md shadow-xl - max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150" + max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 flex flex-col" > + {#if allowCustom} + <div class="px-2 py-2 border-b border-zinc-700/50 mb-1"> + <div class="flex gap-2"> + <input + type="text" + bind:value={customInput} + placeholder="Custom value..." + class="flex-1 bg-black/30 border border-zinc-700 rounded px-2 py-1 text-xs text-white focus:border-indigo-500 outline-none" + onkeydown={(e) => e.key === 'Enter' && handleCustomSubmit()} + onclick={(e) => e.stopPropagation()} + /> + <button + onclick={(e) => { e.stopPropagation(); handleCustomSubmit(); }} + class="px-2 py-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded text-xs transition-colors" + > + Set + </button> + </div> + </div> + {/if} + {#each options as option} <button type="button" diff --git a/ui/src/components/InstancesView.svelte b/ui/src/components/InstancesView.svelte new file mode 100644 index 0000000..a4881e6 --- /dev/null +++ b/ui/src/components/InstancesView.svelte @@ -0,0 +1,331 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import { instancesState } from "../stores/instances.svelte"; + import { Plus, Trash2, Edit2, Copy, Check, X } from "lucide-svelte"; + import type { Instance } from "../types"; + + let showCreateModal = $state(false); + let showEditModal = $state(false); + let showDeleteConfirm = $state(false); + let showDuplicateModal = $state(false); + let selectedInstance: Instance | null = $state(null); + let newInstanceName = $state(""); + let duplicateName = $state(""); + + onMount(() => { + instancesState.loadInstances(); + }); + + function handleCreate() { + newInstanceName = ""; + showCreateModal = true; + } + + function handleEdit(instance: Instance) { + selectedInstance = instance; + newInstanceName = instance.name; + showEditModal = true; + } + + function handleDelete(instance: Instance) { + selectedInstance = instance; + showDeleteConfirm = true; + } + + function handleDuplicate(instance: Instance) { + selectedInstance = instance; + duplicateName = `${instance.name} (Copy)`; + showDuplicateModal = true; + } + + async function confirmCreate() { + if (!newInstanceName.trim()) return; + await instancesState.createInstance(newInstanceName.trim()); + showCreateModal = false; + newInstanceName = ""; + } + + async function confirmEdit() { + if (!selectedInstance || !newInstanceName.trim()) return; + await instancesState.updateInstance({ + ...selectedInstance, + name: newInstanceName.trim(), + }); + showEditModal = false; + selectedInstance = null; + newInstanceName = ""; + } + + async function confirmDelete() { + if (!selectedInstance) return; + await instancesState.deleteInstance(selectedInstance.id); + showDeleteConfirm = false; + selectedInstance = null; + } + + async function confirmDuplicate() { + if (!selectedInstance || !duplicateName.trim()) return; + await instancesState.duplicateInstance(selectedInstance.id, duplicateName.trim()); + showDuplicateModal = false; + selectedInstance = null; + duplicateName = ""; + } + + function formatDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString(); + } + + function formatLastPlayed(timestamp: number): string { + const date = new Date(timestamp * 1000); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) return "Today"; + if (days === 1) return "Yesterday"; + if (days < 7) return `${days} days ago`; + return date.toLocaleDateString(); + } +</script> + +<div class="h-full flex flex-col gap-4 p-6 overflow-y-auto"> + <div class="flex items-center justify-between"> + <h1 class="text-2xl font-bold text-gray-900 dark:text-white">Instances</h1> + <button + onclick={handleCreate} + class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors" + > + <Plus size={18} /> + Create Instance + </button> + </div> + + {#if instancesState.instances.length === 0} + <div class="flex-1 flex items-center justify-center"> + <div class="text-center text-gray-500 dark:text-gray-400"> + <p class="text-lg mb-2">No instances yet</p> + <p class="text-sm">Create your first instance to get started</p> + </div> + </div> + {:else} + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {#each instancesState.instances as instance (instance.id)} + <div + class="relative p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 transition-all cursor-pointer hover:border-blue-500 {instancesState.activeInstanceId === instance.id + ? 'border-blue-500' + : 'border-transparent'}" + onclick={() => instancesState.setActiveInstance(instance.id)} + > + {#if instancesState.activeInstanceId === instance.id} + <div class="absolute top-2 right-2"> + <div class="w-3 h-3 bg-blue-500 rounded-full"></div> + </div> + {/if} + + <div class="flex items-start justify-between mb-2"> + <h3 class="text-lg font-semibold text-gray-900 dark:text-white"> + {instance.name} + </h3> + <div class="flex gap-1"> + <button + onclick={(e) => { + e.stopPropagation(); + handleEdit(instance); + }} + class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors" + title="Edit" + > + <Edit2 size={16} class="text-gray-600 dark:text-gray-400" /> + </button> + <button + onclick={(e) => { + e.stopPropagation(); + handleDuplicate(instance); + }} + class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors" + title="Duplicate" + > + <Copy size={16} class="text-gray-600 dark:text-gray-400" /> + </button> + <button + onclick={(e) => { + e.stopPropagation(); + handleDelete(instance); + }} + class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors" + title="Delete" + > + <Trash2 size={16} class="text-red-600 dark:text-red-400" /> + </button> + </div> + </div> + + <div class="space-y-1 text-sm text-gray-600 dark:text-gray-400"> + {#if instance.version_id} + <p>Version: <span class="font-medium">{instance.version_id}</span></p> + {:else} + <p class="text-gray-400">No version selected</p> + {/if} + + {#if instance.mod_loader && instance.mod_loader !== "vanilla"} + <p> + Mod Loader: <span class="font-medium capitalize">{instance.mod_loader}</span> + {#if instance.mod_loader_version} + <span class="text-gray-500">({instance.mod_loader_version})</span> + {/if} + </p> + {/if} + + <p>Created: {formatDate(instance.created_at)}</p> + + {#if instance.last_played} + <p>Last played: {formatLastPlayed(instance.last_played)}</p> + {/if} + </div> + + {#if instance.notes} + <p class="mt-2 text-sm text-gray-500 dark:text-gray-500 italic"> + {instance.notes} + </p> + {/if} + </div> + {/each} + </div> + {/if} +</div> + +<!-- Create Modal --> +{#if showCreateModal} + <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96"> + <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Create Instance</h2> + <input + type="text" + bind:value={newInstanceName} + placeholder="Instance name" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" + onkeydown={(e) => e.key === "Enter" && confirmCreate()} + autofocus + /> + <div class="flex gap-2 justify-end"> + <button + onclick={() => { + showCreateModal = false; + newInstanceName = ""; + }} + class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" + > + Cancel + </button> + <button + onclick={confirmCreate} + disabled={!newInstanceName.trim()} + class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + > + Create + </button> + </div> + </div> + </div> +{/if} + +<!-- Edit Modal --> +{#if showEditModal && selectedInstance} + <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96"> + <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Edit Instance</h2> + <input + type="text" + bind:value={newInstanceName} + placeholder="Instance name" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" + onkeydown={(e) => e.key === "Enter" && confirmEdit()} + autofocus + /> + <div class="flex gap-2 justify-end"> + <button + onclick={() => { + showEditModal = false; + selectedInstance = null; + newInstanceName = ""; + }} + class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" + > + Cancel + </button> + <button + onclick={confirmEdit} + disabled={!newInstanceName.trim()} + class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + > + Save + </button> + </div> + </div> + </div> +{/if} + +<!-- Delete Confirmation --> +{#if showDeleteConfirm && selectedInstance} + <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96"> + <h2 class="text-xl font-bold mb-4 text-red-600 dark:text-red-400">Delete Instance</h2> + <p class="mb-4 text-gray-700 dark:text-gray-300"> + Are you sure you want to delete "{selectedInstance.name}"? This action cannot be undone and will delete all game data for this instance. + </p> + <div class="flex gap-2 justify-end"> + <button + onclick={() => { + showDeleteConfirm = false; + selectedInstance = null; + }} + class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" + > + Cancel + </button> + <button + onclick={confirmDelete} + class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" + > + Delete + </button> + </div> + </div> + </div> +{/if} + +<!-- Duplicate Modal --> +{#if showDuplicateModal && selectedInstance} + <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96"> + <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Duplicate Instance</h2> + <input + type="text" + bind:value={duplicateName} + placeholder="New instance name" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" + onkeydown={(e) => e.key === "Enter" && confirmDuplicate()} + autofocus + /> + <div class="flex gap-2 justify-end"> + <button + onclick={() => { + showDuplicateModal = false; + selectedInstance = null; + duplicateName = ""; + }} + class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" + > + Cancel + </button> + <button + onclick={confirmDuplicate} + disabled={!duplicateName.trim()} + class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + > + Duplicate + </button> + </div> + </div> + </div> +{/if} diff --git a/ui/src/components/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte index e9d147b..50caa8c 100644 --- a/ui/src/components/ModLoaderSelector.svelte +++ b/ui/src/components/ModLoaderSelector.svelte @@ -9,6 +9,7 @@ } from "../types"; import { Loader2, Download, AlertCircle, Check, ChevronDown, CheckCircle } from 'lucide-svelte'; import { logsState } from "../stores/logs.svelte"; + import { instancesState } from "../stores/instances.svelte"; interface Props { selectedGameVersion: string; @@ -52,12 +53,13 @@ }); async function checkInstallStatus() { - if (!selectedGameVersion) { + if (!selectedGameVersion || !instancesState.activeInstanceId) { isVersionInstalled = false; return; } try { isVersionInstalled = await invoke<boolean>("check_version_installed", { + instanceId: instancesState.activeInstanceId, versionId: selectedGameVersion, }); } catch (e) { @@ -112,8 +114,13 @@ error = null; logsState.addLog("info", "Installer", `Starting installation of ${selectedGameVersion}...`); + if (!instancesState.activeInstanceId) { + error = "Please select an instance first"; + return; + } try { await invoke("install_version", { + instanceId: instancesState.activeInstanceId, versionId: selectedGameVersion, }); logsState.addLog("info", "Installer", `Successfully installed ${selectedGameVersion}`); @@ -134,6 +141,12 @@ return; } + if (!instancesState.activeInstanceId) { + error = "Please select an instance first"; + isInstalling = false; + return; + } + isInstalling = true; error = null; @@ -142,6 +155,7 @@ if (!isVersionInstalled) { logsState.addLog("info", "Installer", `Installing base game ${selectedGameVersion} first...`); await invoke("install_version", { + instanceId: instancesState.activeInstanceId, versionId: selectedGameVersion, }); isVersionInstalled = true; @@ -151,6 +165,7 @@ if (selectedLoader === "fabric" && selectedFabricLoader) { logsState.addLog("info", "Installer", `Installing Fabric ${selectedFabricLoader} for ${selectedGameVersion}...`); const result = await invoke<any>("install_fabric", { + instanceId: instancesState.activeInstanceId, gameVersion: selectedGameVersion, loaderVersion: selectedFabricLoader, }); @@ -159,6 +174,7 @@ } else if (selectedLoader === "forge" && selectedForgeVersion) { logsState.addLog("info", "Installer", `Installing Forge ${selectedForgeVersion} for ${selectedGameVersion}...`); const result = await invoke<any>("install_forge", { + instanceId: instancesState.activeInstanceId, gameVersion: selectedGameVersion, forgeVersion: selectedForgeVersion, }); @@ -291,7 +307,12 @@ {:else if selectedLoader === "fabric"} <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300"> - <div> + {#if fabricLoaders.length === 0} + <div class="text-center p-4 text-sm text-zinc-500 italic"> + No Fabric versions available for {selectedGameVersion} + </div> + {:else} + <div> <label for="fabric-loader-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2" >Loader Version</label > @@ -339,21 +360,22 @@ </div> {/if} </div> - </div> - - <button - class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2" - onclick={installModLoader} - disabled={isInstalling || !selectedFabricLoader} - > - {#if isInstalling} - <Loader2 class="animate-spin" size={16} /> - Installing... - {:else} - <Download size={16} /> - Install Fabric {selectedFabricLoader} - {/if} - </button> + </div> + + <button + class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2" + onclick={installModLoader} + disabled={isInstalling || !selectedFabricLoader} + > + {#if isInstalling} + <Loader2 class="animate-spin" size={16} /> + Installing... + {:else} + <Download size={16} /> + Install Fabric {selectedFabricLoader} + {/if} + </button> + {/if} </div> {:else if selectedLoader === "forge"} diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte index 76d441b..4de18b3 100644 --- a/ui/src/components/SettingsView.svelte +++ b/ui/src/components/SettingsView.svelte @@ -2,6 +2,9 @@ import { open } from "@tauri-apps/plugin-dialog"; import { settingsState } from "../stores/settings.svelte"; import CustomSelect from "./CustomSelect.svelte"; + import ConfigEditorModal from "./ConfigEditorModal.svelte"; + import { onMount } from "svelte"; + import { RefreshCw, FileJson } from "lucide-svelte"; // Use convertFileSrc directly from settingsState.backgroundUrl for cleaner approach // or use the imported one if passing raw path. @@ -17,6 +20,84 @@ { value: "pastebin.com", label: "pastebin.com (Requires API Key)" } ]; + const llmProviderOptions = [ + { value: "ollama", label: "Ollama (Local)" }, + { value: "openai", label: "OpenAI (Remote)" } + ]; + + const languageOptions = [ + { value: "auto", label: "Auto (Match User)" }, + { value: "English", label: "English" }, + { value: "Chinese", label: "中文" }, + { value: "Japanese", label: "日本語" }, + { value: "Korean", label: "한국어" }, + { value: "Spanish", label: "Español" }, + { value: "French", label: "Français" }, + { value: "German", label: "Deutsch" }, + { value: "Russian", label: "Русский" }, + ]; + + const ttsProviderOptions = [ + { value: "disabled", label: "Disabled" }, + { value: "piper", label: "Piper TTS (Local)" }, + { value: "edge", label: "Edge TTS (Online)" }, + ]; + + const personas = [ + { + value: "default", + label: "Minecraft Expert (Default)", + prompt: "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice." + }, + { + value: "technical", + label: "Technical Debugger", + prompt: "You are a technical support specialist for Minecraft. Focus strictly on analyzing logs, identifying crash causes, and providing technical solutions. Be precise and avoid conversational filler." + }, + { + value: "concise", + label: "Concise Helper", + prompt: "You are a direct and concise assistant. Provide answers in as few words as possible while remaining accurate. Use bullet points for lists." + }, + { + value: "explain", + label: "Teacher / Explainer", + prompt: "You are a patient teacher. Explain Minecraft concepts, redstone mechanics, and mod features in simple, easy-to-understand terms suitable for beginners." + }, + { + value: "pirate", + label: "Pirate Captain", + prompt: "You are a salty Minecraft Pirate Captain! Yarr! Speak like a pirate while helping the crew (the user) with their blocky adventures. Use terms like 'matey', 'landlubber', and 'treasure'." + } + ]; + + let selectedPersona = $state(""); + + function applyPersona(value: string) { + const persona = personas.find(p => p.value === value); + if (persona) { + settingsState.settings.assistant.system_prompt = persona.prompt; + selectedPersona = value; // Keep selected to show what's active + } + } + + function resetSystemPrompt() { + const defaultPersona = personas.find(p => p.value === "default"); + if (defaultPersona) { + settingsState.settings.assistant.system_prompt = defaultPersona.prompt; + selectedPersona = "default"; + } + } + + // Load models when assistant settings are shown + function loadModelsForProvider() { + if (settingsState.settings.assistant.llm_provider === "ollama") { + settingsState.loadOllamaModels(); + } else if (settingsState.settings.assistant.llm_provider === "openai") { + settingsState.loadOpenaiModels(); + } + } + async function selectBackground() { try { const selected = await open({ @@ -47,6 +128,15 @@ <div class="h-full flex flex-col p-6 overflow-hidden"> <div class="flex items-center justify-between mb-6"> <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">Settings</h2> + + <button + onclick={() => settingsState.openConfigEditor()} + class="p-2 hover:bg-white/10 rounded-lg text-zinc-400 hover:text-white transition-colors flex items-center gap-2 text-sm border border-transparent hover:border-white/5" + title="Open Settings JSON" + > + <FileJson size={18} /> + <span class="hidden sm:inline">Open JSON</span> + </button> </div> <div class="flex-1 overflow-y-auto pr-2 space-y-6 custom-scrollbar pb-10"> @@ -341,6 +431,298 @@ </div> </div> + <!-- AI Assistant --> + <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm"> + <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2"> + <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <rect x="3" y="11" width="18" height="10" rx="2"/> + <circle cx="12" cy="5" r="2"/> + <path d="M12 7v4"/> + <circle cx="8" cy="16" r="1" fill="currentColor"/> + <circle cx="16" cy="16" r="1" fill="currentColor"/> + </svg> + AI Assistant + </h3> + <div class="space-y-6"> + <!-- Enable/Disable --> + <div class="flex items-center justify-between"> + <div> + <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="assistant-enabled-label">Enable Assistant</h4> + <p class="text-xs dark:text-white/40 text-black/50 mt-1">Toggle the AI assistant feature on or off.</p> + </div> + <button + aria-labelledby="assistant-enabled-label" + onclick={() => { settingsState.settings.assistant.enabled = !settingsState.settings.assistant.enabled; settingsState.saveSettings(); }} + class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.assistant.enabled ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}" + > + <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.assistant.enabled ? 'translate-x-5' : 'translate-x-0'}"></div> + </button> + </div> + + {#if settingsState.settings.assistant.enabled} + <!-- LLM Provider Section --> + <div class="pt-4 border-t dark:border-white/5 border-black/5"> + <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Language Model</h4> + + <div class="space-y-4"> + <div> + <label for="llm-provider" class="block text-sm font-medium text-white/70 mb-2">Provider</label> + <CustomSelect + options={llmProviderOptions} + bind:value={settingsState.settings.assistant.llm_provider} + onchange={() => settingsState.saveSettings()} + class="w-full" + /> + </div> + + {#if settingsState.settings.assistant.llm_provider === 'ollama'} + <!-- Ollama Settings --> + <div class="pl-4 border-l-2 border-indigo-500/30 space-y-4"> + <div> + <label for="ollama-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label> + <div class="flex gap-2"> + <input + id="ollama-endpoint" + type="text" + bind:value={settingsState.settings.assistant.ollama_endpoint} + placeholder="http://localhost:11434" + class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors" + /> + <button + onclick={() => settingsState.loadOllamaModels()} + disabled={settingsState.isLoadingOllamaModels} + class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2" + title="Refresh models" + > + <RefreshCw size={14} class={settingsState.isLoadingOllamaModels ? "animate-spin" : ""} /> + <span class="hidden sm:inline">Refresh</span> + </button> + </div> + <p class="text-xs text-white/30 mt-2"> + Default: http://localhost:11434. Make sure Ollama is running. + </p> + </div> + + <div> + <div class="flex items-center justify-between mb-2"> + <label for="ollama-model" class="block text-sm font-medium text-white/70">Model</label> + {#if settingsState.ollamaModels.length > 0} + <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full"> + {settingsState.ollamaModels.length} installed + </span> + {/if} + </div> + + {#if settingsState.isLoadingOllamaModels} + <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2"> + <RefreshCw size={14} class="animate-spin" /> + Loading models... + </div> + {:else if settingsState.ollamaModelsError} + <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm"> + {settingsState.ollamaModelsError} + </div> + <CustomSelect + options={settingsState.currentModelOptions} + bind:value={settingsState.settings.assistant.ollama_model} + onchange={() => settingsState.saveSettings()} + class="w-full mt-2" + allowCustom={true} + /> + {:else if settingsState.ollamaModels.length === 0} + <div class="bg-amber-500/10 text-amber-400 w-full px-4 py-3 rounded-xl border border-amber-500/20 text-sm"> + No models found. Click Refresh to load installed models. + </div> + <CustomSelect + options={settingsState.currentModelOptions} + bind:value={settingsState.settings.assistant.ollama_model} + onchange={() => settingsState.saveSettings()} + class="w-full mt-2" + allowCustom={true} + /> + {:else} + <CustomSelect + options={settingsState.currentModelOptions} + bind:value={settingsState.settings.assistant.ollama_model} + onchange={() => settingsState.saveSettings()} + class="w-full" + allowCustom={true} + /> + {/if} + + <p class="text-xs text-white/30 mt-2"> + Run <code class="bg-black/30 px-1 rounded">ollama pull {'<model>'}</code> to download new models. Or type a custom model name above. + </p> + </div> + </div> + {:else if settingsState.settings.assistant.llm_provider === 'openai'} + <!-- OpenAI Settings --> + <div class="pl-4 border-l-2 border-emerald-500/30 space-y-4"> + <div> + <label for="openai-key" class="block text-sm font-medium text-white/70 mb-2">API Key</label> + <div class="flex gap-2"> + <input + id="openai-key" + type="password" + bind:value={settingsState.settings.assistant.openai_api_key} + placeholder="sk-..." + class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors" + /> + <button + onclick={() => settingsState.loadOpenaiModels()} + disabled={settingsState.isLoadingOpenaiModels || !settingsState.settings.assistant.openai_api_key} + class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2" + title="Refresh models" + > + <RefreshCw size={14} class={settingsState.isLoadingOpenaiModels ? "animate-spin" : ""} /> + <span class="hidden sm:inline">Load</span> + </button> + </div> + <p class="text-xs text-white/30 mt-2"> + Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" class="text-indigo-400 hover:underline">OpenAI Dashboard</a>. + </p> + </div> + + <div> + <label for="openai-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label> + <input + id="openai-endpoint" + type="text" + bind:value={settingsState.settings.assistant.openai_endpoint} + placeholder="https://api.openai.com/v1" + class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors" + /> + <p class="text-xs text-white/30 mt-2"> + Use custom endpoint for Azure OpenAI or other compatible APIs. + </p> + </div> + + <div> + <div class="flex items-center justify-between mb-2"> + <label for="openai-model" class="block text-sm font-medium text-white/70">Model</label> + {#if settingsState.openaiModels.length > 0} + <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full"> + {settingsState.openaiModels.length} available + </span> + {/if} + </div> + + {#if settingsState.isLoadingOpenaiModels} + <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2"> + <RefreshCw size={14} class="animate-spin" /> + Loading models... + </div> + {:else if settingsState.openaiModelsError} + <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm mb-2"> + {settingsState.openaiModelsError} + </div> + <CustomSelect + options={settingsState.currentModelOptions} + bind:value={settingsState.settings.assistant.openai_model} + onchange={() => settingsState.saveSettings()} + class="w-full" + allowCustom={true} + /> + {:else} + <CustomSelect + options={settingsState.currentModelOptions} + bind:value={settingsState.settings.assistant.openai_model} + onchange={() => settingsState.saveSettings()} + class="w-full" + allowCustom={true} + /> + {/if} + </div> + </div> + {/if} + </div> + </div> + + <!-- Response Settings --> + <div class="pt-4 border-t dark:border-white/5 border-black/5"> + <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Response Settings</h4> + + <div class="space-y-4"> + <div> + <label for="response-lang" class="block text-sm font-medium text-white/70 mb-2">Response Language</label> + <CustomSelect + options={languageOptions} + bind:value={settingsState.settings.assistant.response_language} + onchange={() => settingsState.saveSettings()} + class="w-full" + /> + </div> + + <div> + <div class="flex items-center justify-between mb-2"> + <label for="system-prompt" class="block text-sm font-medium text-white/70">System Prompt</label> + <button + onclick={resetSystemPrompt} + class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1 opacity-80 hover:opacity-100" + title="Reset to default prompt" + > + <RefreshCw size={10} /> + Reset + </button> + </div> + + <div class="mb-3"> + <CustomSelect + options={personas.map(p => ({ value: p.value, label: p.label }))} + bind:value={selectedPersona} + placeholder="Load a preset persona..." + onchange={applyPersona} + class="w-full" + /> + </div> + + <textarea + id="system-prompt" + bind:value={settingsState.settings.assistant.system_prompt} + oninput={() => selectedPersona = ""} + rows="4" + placeholder="You are a helpful Minecraft expert assistant..." + class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none text-sm transition-colors resize-none" + ></textarea> + <p class="text-xs text-white/30 mt-2"> + Customize how the assistant behaves and responds. + </p> + </div> + </div> + </div> + + <!-- TTS Settings --> + <div class="pt-4 border-t dark:border-white/5 border-black/5"> + <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Text-to-Speech (Coming Soon)</h4> + + <div class="space-y-4 opacity-50 pointer-events-none"> + <div class="flex items-center justify-between"> + <div> + <h4 class="text-sm font-medium dark:text-white/90 text-black/80">Enable TTS</h4> + <p class="text-xs dark:text-white/40 text-black/50 mt-1">Read assistant responses aloud.</p> + </div> + <button + disabled + class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none dark:bg-white/10 bg-black/10" + > + <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out translate-x-0"></div> + </button> + </div> + + <div> + <label class="block text-sm font-medium text-white/70 mb-2">TTS Provider</label> + <CustomSelect + options={ttsProviderOptions} + value="disabled" + class="w-full" + /> + </div> + </div> + </div> + {/if} + </div> + </div> + <div class="pt-4 flex justify-end"> <button onclick={() => settingsState.saveSettings()} @@ -352,6 +734,10 @@ </div> </div> +{#if settingsState.showConfigEditor} + <ConfigEditorModal /> +{/if} + <!-- Java Download Modal --> {#if settingsState.showJavaDownloadModal} <div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70"> diff --git a/ui/src/components/Sidebar.svelte b/ui/src/components/Sidebar.svelte index 1d7cc16..83f4ac6 100644 --- a/ui/src/components/Sidebar.svelte +++ b/ui/src/components/Sidebar.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import { uiState } from '../stores/ui.svelte'; - import { Home, Package, Settings } from 'lucide-svelte'; + import { Home, Package, Settings, Bot, Folder } from 'lucide-svelte'; </script> <aside @@ -57,7 +57,7 @@ <!-- Navigation --> <nav class="flex-1 w-full flex flex-col gap-1 px-3"> <!-- Nav Item Helper --> - {#snippet navItem(view, Icon, label)} + {#snippet navItem(view: any, Icon: any, label: string)} <button class="group flex items-center lg:gap-3 justify-center lg:justify-start w-full px-0 lg:px-4 py-2.5 rounded-sm transition-all duration-200 relative {uiState.currentView === view @@ -76,7 +76,9 @@ {/snippet} {@render navItem('home', Home, 'Overview')} + {@render navItem('instances', Folder, 'Instances')} {@render navItem('versions', Package, 'Versions')} + {@render navItem('guide', Bot, 'Assistant')} {@render navItem('settings', Settings, 'Settings')} </nav> diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte index ce354b9..d4d36d5 100644 --- a/ui/src/components/VersionsView.svelte +++ b/ui/src/components/VersionsView.svelte @@ -1,6 +1,8 @@ <script lang="ts"> import { invoke } from "@tauri-apps/api/core"; + import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { gameState } from "../stores/game.svelte"; + import { instancesState } from "../stores/instances.svelte"; import ModLoaderSelector from "./ModLoaderSelector.svelte"; let searchQuery = $state(""); @@ -9,40 +11,139 @@ ); // Filter by version type - let typeFilter = $state<"all" | "release" | "snapshot" | "modded">("all"); + let typeFilter = $state<"all" | "release" | "snapshot" | "installed">("all"); - // Installed modded versions - let installedFabricVersions = $state<string[]>([]); + // Installed modded versions with Java version info (Fabric + Forge) + let installedFabricVersions = $state<Array<{ id: string; javaVersion?: number }>>([]); let isLoadingModded = $state(false); - // Load installed modded versions + // Load installed modded versions with Java version info (both Fabric and Forge) async function loadInstalledModdedVersions() { + if (!instancesState.activeInstanceId) { + installedFabricVersions = []; + isLoadingModded = false; + return; + } isLoadingModded = true; try { - installedFabricVersions = await invoke<string[]>( - "list_installed_fabric_versions" + // Get all installed versions and filter for modded ones (Fabric and Forge) + const allInstalled = await invoke<Array<{ id: string; type: string }>>( + "list_installed_versions", + { instanceId: instancesState.activeInstanceId } ); + + // Filter for Fabric and Forge versions + const moddedIds = allInstalled + .filter(v => v.type === "fabric" || v.type === "forge") + .map(v => v.id); + + // Load Java version for each installed modded version + const versionsWithJava = await Promise.all( + moddedIds.map(async (id) => { + try { + const javaVersion = await invoke<number | null>( + "get_version_java_version", + { + instanceId: instancesState.activeInstanceId!, + versionId: id, + } + ); + return { + id, + javaVersion: javaVersion ?? undefined, + }; + } catch (e) { + console.error(`Failed to get Java version for ${id}:`, e); + return { id, javaVersion: undefined }; + } + }) + ); + + installedFabricVersions = versionsWithJava; } catch (e) { - console.error("Failed to load installed fabric versions:", e); + console.error("Failed to load installed modded versions:", e); } finally { isLoadingModded = false; } } - // Load on mount + let versionDeletedUnlisten: UnlistenFn | null = null; + let downloadCompleteUnlisten: UnlistenFn | null = null; + let versionInstalledUnlisten: UnlistenFn | null = null; + let fabricInstalledUnlisten: UnlistenFn | null = null; + let forgeInstalledUnlisten: UnlistenFn | null = null; + + // Load on mount and setup event listeners $effect(() => { loadInstalledModdedVersions(); + setupEventListeners(); + return () => { + if (versionDeletedUnlisten) { + versionDeletedUnlisten(); + } + if (downloadCompleteUnlisten) { + downloadCompleteUnlisten(); + } + if (versionInstalledUnlisten) { + versionInstalledUnlisten(); + } + if (fabricInstalledUnlisten) { + fabricInstalledUnlisten(); + } + if (forgeInstalledUnlisten) { + forgeInstalledUnlisten(); + } + }; }); + async function setupEventListeners() { + // Refresh versions when a version is deleted + versionDeletedUnlisten = await listen("version-deleted", async () => { + await gameState.loadVersions(); + await loadInstalledModdedVersions(); + }); + + // Refresh versions when a download completes (version installed) + downloadCompleteUnlisten = await listen("download-complete", async () => { + await gameState.loadVersions(); + await loadInstalledModdedVersions(); + }); + + // Refresh when a version is installed + versionInstalledUnlisten = await listen("version-installed", async () => { + await gameState.loadVersions(); + await loadInstalledModdedVersions(); + }); + + // Refresh when Fabric is installed + fabricInstalledUnlisten = await listen("fabric-installed", async () => { + await gameState.loadVersions(); + await loadInstalledModdedVersions(); + }); + + // Refresh when Forge is installed + forgeInstalledUnlisten = await listen("forge-installed", async () => { + await gameState.loadVersions(); + await loadInstalledModdedVersions(); + }); + } + // Combined versions list (vanilla + modded) let allVersions = $derived(() => { - const moddedVersions = installedFabricVersions.map((id) => ({ - id, - type: "fabric", - url: "", - time: "", - releaseTime: new Date().toISOString(), - })); + const moddedVersions = installedFabricVersions.map((v) => { + // Determine type based on version ID + const versionType = v.id.startsWith("fabric-loader-") ? "fabric" : + v.id.includes("-forge-") ? "forge" : "fabric"; + return { + id: v.id, + type: versionType, + url: "", + time: "", + releaseTime: new Date().toISOString(), + javaVersion: v.javaVersion, + isInstalled: true, // Modded versions in the list are always installed + }; + }); return [...moddedVersions, ...gameState.versions]; }); @@ -54,10 +155,8 @@ versions = versions.filter((v) => v.type === "release"); } else if (typeFilter === "snapshot") { versions = versions.filter((v) => v.type === "snapshot"); - } else if (typeFilter === "modded") { - versions = versions.filter( - (v) => v.type === "fabric" || v.type === "forge" - ); + } else if (typeFilter === "installed") { + versions = versions.filter((v) => v.isInstalled === true); } // Apply search filter @@ -90,10 +189,90 @@ function handleModLoaderInstall(versionId: string) { // Refresh the installed versions list loadInstalledModdedVersions(); + // Refresh vanilla versions to update isInstalled status + gameState.loadVersions(); // Select the newly installed version gameState.selectedVersion = versionId; } + // Delete confirmation dialog state + let showDeleteDialog = $state(false); + let versionToDelete = $state<string | null>(null); + + // Show delete confirmation dialog + function showDeleteConfirmation(versionId: string, event: MouseEvent) { + event.stopPropagation(); // Prevent version selection + versionToDelete = versionId; + showDeleteDialog = true; + } + + // Cancel delete + function cancelDelete() { + showDeleteDialog = false; + versionToDelete = null; + } + + // Confirm and delete version + async function confirmDelete() { + if (!versionToDelete) return; + + try { + await invoke("delete_version", { versionId: versionToDelete }); + // Clear selection if deleted version was selected + if (gameState.selectedVersion === versionToDelete) { + gameState.selectedVersion = ""; + } + // Close dialog + showDeleteDialog = false; + versionToDelete = null; + // Versions will be refreshed automatically via event listener + } catch (e) { + console.error("Failed to delete version:", e); + alert(`Failed to delete version: ${e}`); + // Keep dialog open on error so user can retry + } + } + + // Version metadata for the selected version + interface VersionMetadata { + id: string; + javaVersion?: number; + isInstalled: boolean; + } + + let selectedVersionMetadata = $state<VersionMetadata | null>(null); + let isLoadingMetadata = $state(false); + + // Load metadata when version is selected + async function loadVersionMetadata(versionId: string) { + if (!versionId) { + selectedVersionMetadata = null; + return; + } + + isLoadingMetadata = true; + try { + const metadata = await invoke<VersionMetadata>("get_version_metadata", { + versionId, + }); + selectedVersionMetadata = metadata; + } catch (e) { + console.error("Failed to load version metadata:", e); + selectedVersionMetadata = null; + } finally { + isLoadingMetadata = false; + } + } + + // Watch for selected version changes + $effect(() => { + if (gameState.selectedVersion) { + loadVersionMetadata(gameState.selectedVersion); + } else { + selectedVersionMetadata = null; + } + }); + // Get the base Minecraft version from selected version (for mod loader selector) let selectedBaseVersion = $derived(() => { const selected = gameState.selectedVersion; @@ -140,7 +319,7 @@ <!-- Type Filter Tabs (Glass Caps) --> <div class="flex p-1 bg-white/60 dark:bg-black/20 rounded-xl border border-black/5 dark:border-white/5"> - {#each ['all', 'release', 'snapshot', 'modded'] as filter} + {#each ['all', 'release', 'snapshot', 'installed'] as filter} <button class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 capitalize {typeFilter === filter @@ -180,29 +359,52 @@ <div class="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-transparent pointer-events-none"></div> {/if} - <div class="relative z-10 flex items-center gap-4"> + <div class="relative z-10 flex items-center gap-4 flex-1"> <span class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border {badge.class}" > {badge.text} </span> - <div> + <div class="flex-1"> <div class="font-bold font-mono text-lg tracking-tight {isSelected ? 'text-black dark:text-white' : 'text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white'}"> {version.id} </div> - {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"} - <div class="text-xs dark:text-white/30 text-black/30"> - {new Date(version.releaseTime).toLocaleDateString()} - </div> - {/if} + <div class="flex items-center gap-2 mt-0.5"> + {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"} + <div class="text-xs dark:text-white/30 text-black/30"> + {new Date(version.releaseTime).toLocaleDateString()} + </div> + {/if} + {#if version.javaVersion} + <div class="flex items-center gap-1 text-xs dark:text-white/40 text-black/40"> + <span class="opacity-60">☕</span> + <span class="font-medium">Java {version.javaVersion}</span> + </div> + {/if} + </div> </div> </div> - {#if isSelected} - <div class="relative z-10 text-indigo-500 dark:text-indigo-400"> - <span class="text-lg">Selected</span> - </div> - {/if} + <div class="relative z-10 flex items-center gap-2"> + {#if version.isInstalled === true} + <button + onclick={(e) => showDeleteConfirmation(version.id, e)} + class="p-2 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20 transition-colors opacity-0 group-hover:opacity-100" + title="Delete version" + > + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M3 6h18"></path> + <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path> + <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path> + </svg> + </button> + {/if} + {#if isSelected} + <div class="text-indigo-500 dark:text-indigo-400"> + <span class="text-lg">Selected</span> + </div> + {/if} + </div> </button> {/each} {/if} @@ -217,9 +419,50 @@ <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-2 relative z-10">Current Selection</h3> {#if gameState.selectedVersion} - <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate"> + <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate mb-4"> {gameState.selectedVersion} </p> + + <!-- Version Metadata --> + {#if isLoadingMetadata} + <div class="space-y-3 relative z-10"> + <div class="animate-pulse space-y-2"> + <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-3/4"></div> + <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-1/2"></div> + </div> + </div> + {:else if selectedVersionMetadata} + <div class="space-y-3 relative z-10"> + <!-- Java Version --> + {#if selectedVersionMetadata.javaVersion} + <div> + <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Java Version</div> + <div class="flex items-center gap-2"> + <span class="text-lg opacity-60">☕</span> + <span class="text-sm dark:text-white text-black font-medium"> + Java {selectedVersionMetadata.javaVersion} + </span> + </div> + </div> + {/if} + + <!-- Installation Status --> + <div> + <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Status</div> + <div class="flex items-center gap-2"> + {#if selectedVersionMetadata.isInstalled === true} + <span class="px-2 py-0.5 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold rounded border border-emerald-500/30"> + Installed + </span> + {:else if selectedVersionMetadata.isInstalled === false} + <span class="px-2 py-0.5 bg-zinc-500/20 text-zinc-600 dark:text-zinc-400 text-[10px] font-bold rounded border border-zinc-500/30"> + Not Installed + </span> + {/if} + </div> + </div> + </div> + {/if} {:else} <p class="dark:text-white/20 text-black/20 italic relative z-10">None selected</p> {/if} @@ -235,5 +478,30 @@ </div> </div> -</div> + <!-- Delete Version Confirmation Dialog --> + {#if showDeleteDialog && versionToDelete} + <div class="fixed inset-0 z-[200] bg-black/70 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"> + <div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200"> + <h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Delete Version</h3> + <p class="text-zinc-600 dark:text-zinc-400 text-sm mb-6"> + Are you sure you want to delete version <span class="text-gray-900 dark:text-white font-mono font-medium">{versionToDelete}</span>? This action cannot be undone. + </p> + <div class="flex gap-3 justify-end"> + <button + onclick={cancelDelete} + class="px-4 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-colors" + > + Cancel + </button> + <button + onclick={confirmDelete} + class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors" + > + Delete + </button> + </div> + </div> + </div> + {/if} +</div> diff --git a/ui/src/stores/assistant.svelte.ts b/ui/src/stores/assistant.svelte.ts new file mode 100644 index 0000000..a3f47ea --- /dev/null +++ b/ui/src/stores/assistant.svelte.ts @@ -0,0 +1,166 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +export interface GenerationStats { + total_duration: number; + load_duration: number; + prompt_eval_count: number; + prompt_eval_duration: number; + eval_count: number; + eval_duration: number; +} + +export interface Message { + role: "user" | "assistant" | "system"; + content: string; + stats?: GenerationStats; +} + +interface StreamChunk { + content: string; + done: boolean; + stats?: GenerationStats; +} + +// Module-level state using $state +let messages = $state<Message[]>([]); +let isProcessing = $state(false); +let isProviderHealthy = $state(false); +let streamingContent = ""; +let initialized = false; +let streamUnlisten: UnlistenFn | null = null; + +async function init() { + if (initialized) return; + initialized = true; + await checkHealth(); +} + +async function checkHealth() { + try { + isProviderHealthy = await invoke("assistant_check_health"); + } catch (e) { + console.error("Failed to check provider health:", e); + isProviderHealthy = false; + } +} + +function finishStreaming() { + isProcessing = false; + streamingContent = ""; + if (streamUnlisten) { + streamUnlisten(); + streamUnlisten = null; + } +} + +async function sendMessage( + content: string, + isEnabled: boolean, + provider: string, + endpoint: string, +) { + if (!content.trim()) return; + if (!isEnabled) { + messages = [ + ...messages, + { + role: "assistant", + content: "Assistant is disabled. Enable it in Settings > AI Assistant.", + }, + ]; + return; + } + + // Add user message + messages = [...messages, { role: "user", content }]; + isProcessing = true; + streamingContent = ""; + + // Add empty assistant message for streaming + messages = [...messages, { role: "assistant", content: "" }]; + + try { + // Set up stream listener + streamUnlisten = await listen<StreamChunk>("assistant-stream", (event) => { + const chunk = event.payload; + + if (chunk.content) { + streamingContent += chunk.content; + // Update the last message (assistant's response) + const lastIdx = messages.length - 1; + if (lastIdx >= 0 && messages[lastIdx].role === "assistant") { + messages[lastIdx] = { + ...messages[lastIdx], + content: streamingContent, + }; + // Trigger reactivity + messages = [...messages]; + } + } + + if (chunk.done) { + if (chunk.stats) { + const lastIdx = messages.length - 1; + if (lastIdx >= 0 && messages[lastIdx].role === "assistant") { + messages[lastIdx] = { + ...messages[lastIdx], + stats: chunk.stats, + }; + messages = [...messages]; + } + } + finishStreaming(); + } + }); + + // Start streaming chat + await invoke<string>("assistant_chat_stream", { + messages: messages.slice(0, -1), // Exclude the empty assistant message + }); + } catch (e) { + console.error("Failed to send message:", e); + const errorMessage = e instanceof Error ? e.message : String(e); + + let helpText = ""; + if (provider === "ollama") { + helpText = `\n\nPlease ensure Ollama is running at ${endpoint}.`; + } else if (provider === "openai") { + helpText = "\n\nPlease check your OpenAI API key in Settings."; + } + + // Update the last message with error + const lastIdx = messages.length - 1; + if (lastIdx >= 0 && messages[lastIdx].role === "assistant") { + messages[lastIdx] = { + role: "assistant", + content: `Error: ${errorMessage}${helpText}`, + }; + messages = [...messages]; + } + + finishStreaming(); + } +} + +function clearHistory() { + messages = []; + streamingContent = ""; +} + +// Export as an object with getters for reactive access +export const assistantState = { + get messages() { + return messages; + }, + get isProcessing() { + return isProcessing; + }, + get isProviderHealthy() { + return isProviderHealthy; + }, + init, + checkHealth, + sendMessage, + clearHistory, +}; diff --git a/ui/src/stores/game.svelte.ts b/ui/src/stores/game.svelte.ts index 28b2db5..1e4119f 100644 --- a/ui/src/stores/game.svelte.ts +++ b/ui/src/stores/game.svelte.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { Version } from "../types"; import { uiState } from "./ui.svelte"; import { authState } from "./auth.svelte"; +import { instancesState } from "./instances.svelte"; export class GameState { versions = $state<Version[]>([]); @@ -14,10 +15,8 @@ export class GameState { async loadVersions() { try { this.versions = await invoke<Version[]>("get_versions"); - if (this.versions.length > 0) { - const latest = this.versions.find((v) => v.type === "release"); - this.selectedVersion = latest ? latest.id : this.versions[0].id; - } + // Don't auto-select version here - let BottomBar handle version selection + // based on installed versions only } catch (e) { console.error("Failed to fetch versions:", e); uiState.setStatus("Error fetching versions: " + e); @@ -36,10 +35,24 @@ export class GameState { return; } + if (!instancesState.activeInstanceId) { + alert("Please select an instance first!"); + uiState.setView("instances"); + return; + } + uiState.setStatus("Preparing to launch " + this.selectedVersion + "..."); - console.log("Invoking start_game for version:", this.selectedVersion); + console.log( + "Invoking start_game for version:", + this.selectedVersion, + "instance:", + instancesState.activeInstanceId, + ); try { - const msg = await invoke<string>("start_game", { versionId: this.selectedVersion }); + const msg = await invoke<string>("start_game", { + instanceId: instancesState.activeInstanceId, + versionId: this.selectedVersion, + }); console.log("Response:", msg); uiState.setStatus(msg); } catch (e) { diff --git a/ui/src/stores/instances.svelte.ts b/ui/src/stores/instances.svelte.ts new file mode 100644 index 0000000..f4ac4e9 --- /dev/null +++ b/ui/src/stores/instances.svelte.ts @@ -0,0 +1,109 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { Instance } from "../types"; +import { uiState } from "./ui.svelte"; + +export class InstancesState { + instances = $state<Instance[]>([]); + activeInstanceId = $state<string | null>(null); + get activeInstance(): Instance | null { + if (!this.activeInstanceId) return null; + return this.instances.find((i) => i.id === this.activeInstanceId) || null; + } + + async loadInstances() { + try { + this.instances = await invoke<Instance[]>("list_instances"); + const active = await invoke<Instance | null>("get_active_instance"); + if (active) { + this.activeInstanceId = active.id; + } else if (this.instances.length > 0) { + // If no active instance but instances exist, set the first one as active + await this.setActiveInstance(this.instances[0].id); + } + } catch (e) { + console.error("Failed to load instances:", e); + uiState.setStatus("Error loading instances: " + e); + } + } + + async createInstance(name: string): Promise<Instance | null> { + try { + const instance = await invoke<Instance>("create_instance", { name }); + await this.loadInstances(); + uiState.setStatus(`Instance "${name}" created successfully`); + return instance; + } catch (e) { + console.error("Failed to create instance:", e); + uiState.setStatus("Error creating instance: " + e); + return null; + } + } + + async deleteInstance(id: string) { + try { + await invoke("delete_instance", { instanceId: id }); + await this.loadInstances(); + // If deleted instance was active, set another as active + if (this.activeInstanceId === id) { + if (this.instances.length > 0) { + await this.setActiveInstance(this.instances[0].id); + } else { + this.activeInstanceId = null; + } + } + uiState.setStatus("Instance deleted successfully"); + } catch (e) { + console.error("Failed to delete instance:", e); + uiState.setStatus("Error deleting instance: " + e); + } + } + + async updateInstance(instance: Instance) { + try { + await invoke("update_instance", { instance }); + await this.loadInstances(); + uiState.setStatus("Instance updated successfully"); + } catch (e) { + console.error("Failed to update instance:", e); + uiState.setStatus("Error updating instance: " + e); + } + } + + async setActiveInstance(id: string) { + try { + await invoke("set_active_instance", { instanceId: id }); + this.activeInstanceId = id; + uiState.setStatus("Active instance changed"); + } catch (e) { + console.error("Failed to set active instance:", e); + uiState.setStatus("Error setting active instance: " + e); + } + } + + async duplicateInstance(id: string, newName: string): Promise<Instance | null> { + try { + const instance = await invoke<Instance>("duplicate_instance", { + instanceId: id, + newName, + }); + await this.loadInstances(); + uiState.setStatus(`Instance duplicated as "${newName}"`); + return instance; + } catch (e) { + console.error("Failed to duplicate instance:", e); + uiState.setStatus("Error duplicating instance: " + e); + return null; + } + } + + async getInstance(id: string): Promise<Instance | null> { + try { + return await invoke<Instance>("get_instance", { instanceId: id }); + } catch (e) { + console.error("Failed to get instance:", e); + return null; + } + } +} + +export const instancesState = new InstancesState(); diff --git a/ui/src/stores/logs.svelte.ts b/ui/src/stores/logs.svelte.ts index 5df9abc..c9d4acc 100644 --- a/ui/src/stores/logs.svelte.ts +++ b/ui/src/stores/logs.svelte.ts @@ -39,7 +39,6 @@ export class LogsState { constructor() { this.addLog("info", "Launcher", "Logs initialized"); - this.setupListeners(); } addLog(level: LogEntry["level"], source: string, message: string) { @@ -95,7 +94,12 @@ export class LogsState { .join("\n"); } - private async setupListeners() { + private initialized = false; + + async init() { + if (this.initialized) return; + this.initialized = true; + // General Launcher Logs await listen<string>("launcher-log", (e) => { this.addLog("info", "Launcher", e.payload); diff --git a/ui/src/stores/settings.svelte.ts b/ui/src/stores/settings.svelte.ts index 12e4a1c..8a90736 100644 --- a/ui/src/stores/settings.svelte.ts +++ b/ui/src/stores/settings.svelte.ts @@ -8,6 +8,7 @@ import type { JavaInstallation, JavaReleaseInfo, LauncherConfig, + ModelInfo, PendingJavaDownload, } from "../types"; import { uiState } from "./ui.svelte"; @@ -27,6 +28,20 @@ export class SettingsState { custom_background_path: undefined, log_upload_service: "paste.rs", pastebin_api_key: undefined, + assistant: { + enabled: true, + llm_provider: "ollama", + ollama_endpoint: "http://localhost:11434", + ollama_model: "llama3", + openai_api_key: undefined, + openai_endpoint: "https://api.openai.com/v1", + openai_model: "gpt-3.5-turbo", + system_prompt: + "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.", + response_language: "auto", + tts_enabled: false, + tts_provider: "disabled", + }, }); // Convert background path to proper asset URL @@ -62,9 +77,58 @@ export class SettingsState { // Pending downloads pendingDownloads = $state<PendingJavaDownload[]>([]); + // AI Model lists + ollamaModels = $state<ModelInfo[]>([]); + openaiModels = $state<ModelInfo[]>([]); + isLoadingOllamaModels = $state(false); + isLoadingOpenaiModels = $state(false); + ollamaModelsError = $state(""); + openaiModelsError = $state(""); + + // Config Editor state + showConfigEditor = $state(false); + rawConfigContent = $state(""); + configFilePath = $state(""); + configEditorError = $state(""); + // Event listener cleanup private progressUnlisten: UnlistenFn | null = null; + async openConfigEditor() { + this.configEditorError = ""; + try { + const path = await invoke<string>("get_config_path"); + const content = await invoke<string>("read_raw_config"); + this.configFilePath = path; + this.rawConfigContent = content; + this.showConfigEditor = true; + } catch (e) { + console.error("Failed to open config editor:", e); + uiState.setStatus(`Failed to open config: ${e}`); + } + } + + async saveRawConfig(content: string, closeAfterSave = true) { + try { + await invoke("save_raw_config", { content }); + // Reload settings to ensure UI is in sync + await this.loadSettings(); + if (closeAfterSave) { + this.showConfigEditor = false; + } + uiState.setStatus("Configuration saved successfully!"); + } catch (e) { + console.error("Failed to save config:", e); + this.configEditorError = String(e); + } + } + + closeConfigEditor() { + this.showConfigEditor = false; + this.rawConfigContent = ""; + this.configEditorError = ""; + } + // Computed: filtered releases based on selection get filteredReleases(): JavaReleaseInfo[] { if (!this.javaCatalog) return []; @@ -389,6 +453,109 @@ export class SettingsState { get availableJavaVersions(): number[] { return this.availableMajorVersions; } + + // AI Model loading methods + async loadOllamaModels() { + this.isLoadingOllamaModels = true; + this.ollamaModelsError = ""; + + try { + const models = await invoke<ModelInfo[]>("list_ollama_models", { + endpoint: this.settings.assistant.ollama_endpoint, + }); + this.ollamaModels = models; + + // If no model is selected or selected model isn't available, select the first one + if (models.length > 0) { + const currentModel = this.settings.assistant.ollama_model; + const modelExists = models.some((m) => m.id === currentModel); + if (!modelExists) { + this.settings.assistant.ollama_model = models[0].id; + } + } + } catch (e) { + console.error("Failed to load Ollama models:", e); + this.ollamaModelsError = String(e); + this.ollamaModels = []; + } finally { + this.isLoadingOllamaModels = false; + } + } + + async loadOpenaiModels() { + if (!this.settings.assistant.openai_api_key) { + this.openaiModelsError = "API key required"; + this.openaiModels = []; + return; + } + + this.isLoadingOpenaiModels = true; + this.openaiModelsError = ""; + + try { + const models = await invoke<ModelInfo[]>("list_openai_models"); + this.openaiModels = models; + + // If no model is selected or selected model isn't available, select the first one + if (models.length > 0) { + const currentModel = this.settings.assistant.openai_model; + const modelExists = models.some((m) => m.id === currentModel); + if (!modelExists) { + this.settings.assistant.openai_model = models[0].id; + } + } + } catch (e) { + console.error("Failed to load OpenAI models:", e); + this.openaiModelsError = String(e); + this.openaiModels = []; + } finally { + this.isLoadingOpenaiModels = false; + } + } + + // Computed: get model options for current provider + get currentModelOptions(): { value: string; label: string; details?: string }[] { + const provider = this.settings.assistant.llm_provider; + + if (provider === "ollama") { + if (this.ollamaModels.length === 0) { + // Return fallback options if no models loaded + return [ + { value: "llama3", label: "Llama 3" }, + { value: "llama3.1", label: "Llama 3.1" }, + { value: "llama3.2", label: "Llama 3.2" }, + { value: "mistral", label: "Mistral" }, + { value: "gemma2", label: "Gemma 2" }, + { value: "qwen2.5", label: "Qwen 2.5" }, + { value: "phi3", label: "Phi-3" }, + { value: "codellama", label: "Code Llama" }, + ]; + } + return this.ollamaModels.map((m) => ({ + value: m.id, + label: m.name, + details: m.size ? `${m.size}${m.details ? ` - ${m.details}` : ""}` : m.details, + })); + } else if (provider === "openai") { + if (this.openaiModels.length === 0) { + // Return fallback options if no models loaded + return [ + { value: "gpt-4o", label: "GPT-4o" }, + { value: "gpt-4o-mini", label: "GPT-4o Mini" }, + { value: "gpt-4-turbo", label: "GPT-4 Turbo" }, + { value: "gpt-4", label: "GPT-4" }, + { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" }, + ]; + } + return this.openaiModels.map((m) => ({ + value: m.id, + label: m.name, + details: m.details, + })); + } + + return []; + } } export const settingsState = new SettingsState(); diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 83e7f9e..a5b336e 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -1,4 +1,4 @@ -export type ViewType = "home" | "versions" | "settings"; +export type ViewType = "home" | "versions" | "settings" | "guide" | "instances"; export interface Version { id: string; @@ -6,6 +6,8 @@ export interface Version { url: string; time: string; releaseTime: string; + javaVersion?: number; // Java major version requirement (e.g., 8, 17, 21) + isInstalled?: boolean; // Whether this version is installed locally } export interface Account { @@ -26,6 +28,31 @@ export interface DeviceCodeResponse { message?: string; } +export interface AssistantConfig { + enabled: boolean; + llm_provider: "ollama" | "openai"; + // Ollama settings + ollama_endpoint: string; + ollama_model: string; + // OpenAI settings + openai_api_key?: string; + openai_endpoint: string; + openai_model: string; + // Common settings + system_prompt: string; + response_language: string; + // TTS settings + tts_enabled: boolean; + tts_provider: string; +} + +export interface ModelInfo { + id: string; + name: string; + size?: string; + details?: string; +} + export interface LauncherConfig { min_memory: number; max_memory: number; @@ -40,6 +67,7 @@ export interface LauncherConfig { theme: string; log_upload_service: "paste.rs" | "pastebin.com"; pastebin_api_key?: string; + assistant: AssistantConfig; } export interface JavaInstallation { @@ -159,3 +187,18 @@ export interface InstalledForgeVersion { // ==================== Mod Loader Type ==================== export type ModLoaderType = "vanilla" | "fabric" | "forge"; + +// ==================== Instance Types ==================== + +export interface Instance { + id: string; + name: string; + game_dir: string; + version_id?: string; + created_at: number; + last_played?: number; + icon_path?: string; + notes?: string; + mod_loader?: string; + mod_loader_version?: string; +} diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json index 31c18cf..addb46d 100644 --- a/ui/tsconfig.app.json +++ b/ui/tsconfig.app.json @@ -5,6 +5,7 @@ "target": "ES2022", "useDefineForClassFields": true, "module": "ESNext", + "moduleResolution": "bundler", "types": ["svelte", "vite/client"], "noEmit": true, /** @@ -0,0 +1,203 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, +] + +[[package]] +name = "dropout" +source = { editable = "." } +dependencies = [ + { name = "pre-commit" }, +] + +[package.dev-dependencies] +dev = [ + { name = "prek" }, +] + +[package.metadata] +requires-dist = [{ name = "pre-commit", specifier = ">=4.5.1" }] + +[package.metadata.requires-dev] +dev = [{ name = "prek", specifier = ">=0.2.28" }] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701 }, +] + +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202 }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731 }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437 }, +] + +[[package]] +name = "prek" +version = "0.2.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/d7/dda8a6b7819bdb9d1f54fa046911e80974d862bacba75a3539b43a9bb858/prek-0.2.28.tar.gz", hash = "sha256:ac54f58cad26e617a5c5459b705ff1cbaaa41640db03d8d35e39645aca1b82cf", size = 283945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/8c/2e18867e31d06dfa4974bf31c4e597dc1d2b3671b3c04d85f1f6a32ebc74/prek-0.2.28-py3-none-linux_armv6l.whl", hash = "sha256:1705c0bd035379cb5f1d03c19481821363d72d7923303fe8c84fd8cc7c6c3318", size = 4802811 }, + { url = "https://files.pythonhosted.org/packages/26/fa/6c6d0b0d8b2f21301da2bb3441f22232ed5a8cba1b63eeb18244d2192a2e/prek-0.2.28-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:67677c08767c278335b31ebcbf00c1c73e14eadd99a0d8537dfb43c54482673a", size = 4904156 }, + { url = "https://files.pythonhosted.org/packages/fc/5a/aa071ef1c2e6c3f58b50d9138676c96dd6de2323a44e1a3e56e18d25c382/prek-0.2.28-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffac92215058ea6ba954a8e3f978dcd2a5e89922a318fcb7035fb9c0ab4de395", size = 4630803 }, + { url = "https://files.pythonhosted.org/packages/77/dc/66498e805a0bb17820de0c3575d75b202c66045a9bfeeff9305d9bedd126/prek-0.2.28-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:413c3da1c9b252b3fd5113f4a04c2dead3c793b0ec56fc55294bb797ba7ca056", size = 4826037 }, + { url = "https://files.pythonhosted.org/packages/27/ad/99cccc9283c7b34cd92356fcb301a2b1c25a8b65dc34b86c671b0f8e29d8/prek-0.2.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e99639bb68b70704e7b3f7f1a51cb23527c4dbd4b8a01dfccaa70f26f8f6c58b", size = 4723658 }, + { url = "https://files.pythonhosted.org/packages/53/13/ce3edc2dda7b65485612e08ab038b8dd1ef7b10a94b0193f527b19a5e246/prek-0.2.28-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33c4a1b2a8a76581476ae327d6d4f1b0af6af42a90d436e21e45c72cb1081b81", size = 5044611 }, + { url = "https://files.pythonhosted.org/packages/48/47/6405d7ad7959d9b57d56fec9a1b4b2e00abeb955084dd45d100fb50a8377/prek-0.2.28-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6236dda18152fc56b9f802ce2914fbb2d19f9891595d552228c7894004b3332f", size = 5511371 }, + { url = "https://files.pythonhosted.org/packages/92/cc/108c227fae40268ece36b80e5649037f1a816518e9b6d585d128b263df79/prek-0.2.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6506709d9a52ee431d48b249b8b5fb93597a9511eb260ee85d868750425057f", size = 5099352 }, + { url = "https://files.pythonhosted.org/packages/12/d6/156ad3996d3a078a1bc2c0839b8681634216a494dcb298b8751beb28b327/prek-0.2.28-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7cc920c12613440c105a767dc19acf048e8a05342ba38b48450d673bea33bd62", size = 4834340 }, + { url = "https://files.pythonhosted.org/packages/f4/06/c632d4c4bb9c63d25bcc26149f99c715206a40e414fb6b80e7f800ae2e2d/prek-0.2.28-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:322a1922e6d2fcb2a4c487e01b2613856dc3206269bdc317ad28770704159e63", size = 4844870 }, + { url = "https://files.pythonhosted.org/packages/ba/03/763f62d292399ee962e2583e7bc3fd2f8ee2609813c89cc10ec89a39204c/prek-0.2.28-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:a07baefe3562371368135eac3c8b9cdb831bac17b83cb1b6a8f5688050918e6c", size = 4709011 }, + { url = "https://files.pythonhosted.org/packages/e1/62/49397d1a5c2aaf5e7a8c0644be901ee97934a8a2cac0052652d01b7c6585/prek-0.2.28-py3-none-musllinux_1_1_i686.whl", hash = "sha256:17e95cab33067365028ffc1d4ab6c80c6c150f88e352d7c64bdc15e0570778f6", size = 4928435 }, + { url = "https://files.pythonhosted.org/packages/5e/e8/8ec73b5bb3fb9d5daf77f181cc46c541bd476075c7613f9b4c9c953925cc/prek-0.2.28-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:bc5272e2e8d81438a3cd2785c3b14b6e73dcb8e61b8a2b7ce6e693d57f7181ac", size = 5209880 }, + { url = "https://files.pythonhosted.org/packages/f3/52/a784a52bf72619bdfaafdbb6c964bda404b377213e7dc786f9ad6024501c/prek-0.2.28-py3-none-win32.whl", hash = "sha256:74657a1663191fb5f09f15873704c2d2640095ce56277d36860bbd08ba7eea94", size = 4622786 }, + { url = "https://files.pythonhosted.org/packages/d0/b8/ec6aafefeb05ef3a0bfcc044d036890f2b732b90ed1426acbf1e33289a44/prek-0.2.28-py3-none-win_amd64.whl", hash = "sha256:c350046d623362db65e2be1455ef1c5a96ea476e235445752fa946a705f1c1c9", size = 5316389 }, + { url = "https://files.pythonhosted.org/packages/df/3f/9d4aba92cb9199cad0b056de8292a78dcca1dc1f6a6a34550799f19bde3d/prek-0.2.28-py3-none-win_arm64.whl", hash = "sha256:81db6ba7e5cf1d5ceec7d3b04e01aded32b8db8f1238ad812ac6ebc0bd35f141", size = 4974627 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "virtualenv" +version = "20.36.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258 }, +] |