aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.yml211
-rw-r--r--.github/ISSUE_TEMPLATE/cn-bug-report.yml211
-rw-r--r--.github/ISSUE_TEMPLATE/cn-feature-request.yml118
-rw-r--r--.github/ISSUE_TEMPLATE/cn-question.yml87
-rw-r--r--.github/ISSUE_TEMPLATE/config.yml11
-rw-r--r--.github/ISSUE_TEMPLATE/feature_request.yml118
-rw-r--r--.github/ISSUE_TEMPLATE/question.yml87
-rw-r--r--.github/PULL_REQUEST_TEMPLATE/cn-pull_request_template.md127
-rw-r--r--.github/PULL_REQUEST_TEMPLATE/en-pull_request_template.md127
-rw-r--r--.github/agents/commit.agent.md260
-rw-r--r--.github/copilot-instructions.md289
-rw-r--r--.github/instructions/commit.instructions.md38
-rw-r--r--.github/references/git/conventional-commit.md153
-rw-r--r--.github/workflows/check.yml4
-rw-r--r--.github/workflows/issue-checkbox-checker.yml104
-rw-r--r--.github/workflows/prek.yml60
-rw-r--r--.github/workflows/stale.yml92
-rw-r--r--.github/workflows/test.yml72
-rw-r--r--.gitignore21
-rw-r--r--.pre-commit-config.yaml39
-rw-r--r--CNAME2
-rw-r--r--README.md16
-rw-r--r--_version.py1
-rw-r--r--assets/image.pngbin163436 -> 177250 bytes
-rw-r--r--pyproject.toml23
-rw-r--r--src-tauri/Cargo.toml8
-rw-r--r--src-tauri/icons/icon.svg2
-rw-r--r--src-tauri/src/core/account_storage.rs2
-rw-r--r--src-tauri/src/core/assistant.rs694
-rw-r--r--src-tauri/src/core/auth.rs7
-rw-r--r--src-tauri/src/core/config.rs40
-rw-r--r--src-tauri/src/core/downloader.rs33
-rw-r--r--src-tauri/src/core/fabric.rs18
-rw-r--r--src-tauri/src/core/forge.rs186
-rw-r--r--src-tauri/src/core/instance.rs325
-rw-r--r--src-tauri/src/core/java.rs158
-rw-r--r--src-tauri/src/core/manifest.rs16
-rw-r--r--src-tauri/src/core/maven.rs5
-rw-r--r--src-tauri/src/core/mod.rs2
-rw-r--r--src-tauri/src/core/version_merge.rs2
-rw-r--r--src-tauri/src/main.rs765
-rw-r--r--src-tauri/src/utils/mod.rs3
-rw-r--r--src-tauri/src/utils/path.rs247
-rw-r--r--src-tauri/tauri.conf.json4
-rw-r--r--ui/package.json6
-rw-r--r--ui/pnpm-lock.yaml47
-rw-r--r--ui/public/vite.svg2
-rw-r--r--ui/src/App.svelte26
-rw-r--r--ui/src/assets/svelte.svg2
-rw-r--r--ui/src/components/AssistantView.svelte436
-rw-r--r--ui/src/components/BottomBar.svelte66
-rw-r--r--ui/src/components/ConfigEditorModal.svelte369
-rw-r--r--ui/src/components/CustomSelect.svelte43
-rw-r--r--ui/src/components/InstancesView.svelte331
-rw-r--r--ui/src/components/ModLoaderSelector.svelte56
-rw-r--r--ui/src/components/SettingsView.svelte386
-rw-r--r--ui/src/components/Sidebar.svelte6
-rw-r--r--ui/src/components/VersionsView.svelte336
-rw-r--r--ui/src/stores/assistant.svelte.ts166
-rw-r--r--ui/src/stores/game.svelte.ts25
-rw-r--r--ui/src/stores/instances.svelte.ts109
-rw-r--r--ui/src/stores/logs.svelte.ts8
-rw-r--r--ui/src/stores/settings.svelte.ts167
-rw-r--r--ui/src/types/index.ts45
-rw-r--r--ui/tsconfig.app.json1
-rw-r--r--uv.lock203
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
diff --git a/.gitignore b/.gitignore
index 3f0ebb6..4d4229e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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$
diff --git a/CNAME b/CNAME
index ed64ffa..b35b671 100644
--- a/CNAME
+++ b/CNAME
@@ -1 +1 @@
-dropout.hydroroll.team \ No newline at end of file
+dropout.hydroroll.team
diff --git a/README.md b/README.md
index d702fe9..337816f 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,13 @@
-# DropOut
+# Drop*O*ut
+
+[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut.svg?type=small)](https://app.fossa.com/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut?ref=badge_small)
+[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
+[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/HsiangNianian/DropOut/main.svg)](https://results.pre-commit.ci/latest/github/HsiangNianian/DropOut/main)
+[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
+[![CodeQL Advanced](https://github.com/HsiangNianian/DropOut/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/HsiangNianian/DropOut/actions/workflows/codeql.yml)
+[![Dependabot Updates](https://github.com/HsiangNianian/DropOut/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/HsiangNianian/DropOut/actions/workflows/dependabot/dependabot-updates)
+[![Release](https://github.com/HsiangNianian/DropOut/actions/workflows/release.yml/badge.svg)](https://github.com/HsiangNianian/DropOut/actions/workflows/release.yml)
+[![Test & Build](https://github.com/HsiangNianian/DropOut/actions/workflows/test.yml/badge.svg)](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
+[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut?ref=badge_shield&issueType=license)
+[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut.svg?type=shield&issueType=security)](https://app.fossa.com/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut?ref=badge_shield&issueType=security)
+
+[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut.svg?type=large)](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
index db6ada3..5bd52e1 100644
--- a/assets/image.png
+++ b/assets/image.png
Binary files differ
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,
/**
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..15d682d
--- /dev/null
+++ b/uv.lock
@@ -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 },
+]