aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
author简律纯 <i@jyunko.cn>2026-02-23 17:18:10 +0800
committerGitHub <noreply@github.com>2026-02-23 17:18:10 +0800
commit8facaa33ba1b590684671d66d55eeeeb868f119c (patch)
treeea919235694d1151ec16e5542eca6e858b5bb30d
parent01b546cc816c4fb6b7389e5122b7802d7e724a2b (diff)
parent83cab95205b2a42d8a1efffbe76b0f0b8fa26eab (diff)
downloadDropOut-8facaa33ba1b590684671d66d55eeeeb868f119c.tar.gz
DropOut-8facaa33ba1b590684671d66d55eeeeb868f119c.zip
Merge branch 'main' into refactor/migrate-to-react
-rw-r--r--.github/workflows/semifold-ci.yaml21
-rw-r--r--.markdownlint.json5
-rw-r--r--README.CN.md13
-rw-r--r--README.md13
-rw-r--r--packages/docs/README.md7
-rw-r--r--packages/docs/content/zh/getting-started.mdx2
-rw-r--r--src-tauri/Cargo.toml10
-rw-r--r--src-tauri/build.rs6
-rw-r--r--src-tauri/icon.rc1
-rw-r--r--src-tauri/src/core/mod.rs1
-rw-r--r--src-tauri/src/core/modpack.rs489
11 files changed, 553 insertions, 15 deletions
diff --git a/.github/workflows/semifold-ci.yaml b/.github/workflows/semifold-ci.yaml
index 7938a9d..8aecd90 100644
--- a/.github/workflows/semifold-ci.yaml
+++ b/.github/workflows/semifold-ci.yaml
@@ -1,7 +1,7 @@
name: Semifold CI
on:
push:
- branches: [main]
+ branches: [main, dev]
env:
CARGO_TERM_COLOR: always
@@ -28,6 +28,8 @@ jobs:
# name: "Linux x86_64 (Musl)"
# target: "x86_64-unknown-linux-musl"
# args: "--target x86_64-unknown-linux-musl"
+ # install-musl: true
+ # pkg-config-allow-cross: true
- platform: "ubuntu-24.04-arm"
name: "Linux arm64"
target: "aarch64-unknown-linux-gnu"
@@ -46,10 +48,11 @@ jobs:
name: "Windows x86_64 (MSVC)"
target: "x86_64-pc-windows-msvc"
args: "--target x86_64-pc-windows-msvc --bundles nsis"
- # - platform: "windows-latest"
- # name: "Windows x86_64 (GNU)"
- # target: "x86_64-pc-windows-gnu"
- # args: "--target x86_64-pc-windows-gnu --bundles nsis"
+ - platform: "windows-latest"
+ name: "Windows x86_64 (GNU)"
+ target: "x86_64-pc-windows-gnu"
+ args: "--target x86_64-pc-windows-gnu --bundles nsis"
+ rustflags: "-C link-arg=-lws2_32"
- platform: "windows-11-arm"
name: "Windows arm64"
target: "aarch64-pc-windows-msvc"
@@ -65,6 +68,11 @@ jobs:
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 libfuse2
+ - name: Install Musl Tools (for musl target)
+ if: matrix.install-musl == true
+ run: |
+ sudo apt-get install -y musl-tools musl-dev
+
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -104,6 +112,9 @@ jobs:
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RUSTFLAGS: ${{ matrix.rustflags || '' }}
+ CARGO_BUILD_TARGET: ${{ matrix.target }}
+ PKG_CONFIG_ALLOW_CROSS: ${{ matrix.pkg-config-allow-cross && '1' || '' }}
with:
args: ${{ matrix.args }}
diff --git a/.markdownlint.json b/.markdownlint.json
new file mode 100644
index 0000000..4c98f54
--- /dev/null
+++ b/.markdownlint.json
@@ -0,0 +1,5 @@
+{
+ "MD013": false,
+ "MD033": false,
+ "MD041": false
+}
diff --git a/README.CN.md b/README.CN.md
index ff5cdd4..69773c6 100644
--- a/README.CN.md
+++ b/README.CN.md
@@ -1,5 +1,7 @@
# Drop*O*ut
+[English](README.md) | 中文
+
[![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)
@@ -9,7 +11,7 @@
[![Semifold CI](https://github.com/HydroRoll-Team/DropOut/actions/workflows/semifold-ci.yaml/badge.svg)](https://github.com/HydroRoll-Team/DropOut/actions/workflows/release.yml)
[![Test & Build](https://github.com/HydroRoll-Team/DropOut/actions/workflows/test.yml/badge.svg)](https://github.com/HydroRoll-Team/DropOut/actions/workflows/test.yml)
-DropOut 是一个现代的、可重现的、开发者级别的 Minecraft 启动器。
+DropOut 是一个现代的、可复现的、开发者级别的 Minecraft 启动器。
它不仅仅是为了启动 Minecraft 而设计的,而是将 Minecraft 环境作为确定性的、版本化的工作空间进行管理。
使用 Tauri v2 和 Rust 构建,DropOut 提供原生性能和最小资源使用,并配有现代响应式 Web UI(目前使用 Svelte 5,正在迁移到 React)。
@@ -98,6 +100,7 @@ DropOut 专注于保持你的游戏稳定、可调试和可重现。
git clone https://github.com/HsiangNianian/DropOut.git
cd DropOut
```
+
2. **安装前端依赖**
```bash
@@ -105,12 +108,14 @@ DropOut 专注于保持你的游戏稳定、可调试和可重现。
pnpm install
cd ..
```
+
3. **运行开发模式**
```bash
# 这将启动前端服务器和 Tauri 应用窗口
cargo tauri dev
```
+
4. **构建发布版本**
```bash
@@ -123,6 +128,7 @@ DropOut 专注于保持你的游戏稳定、可调试和可重现。
DropOut 以长期可维护性为目标构建。
欢迎贡献,尤其在这些领域:
+
- 实例系统设计
- 模组兼容性工具
- UI/UX 改进
@@ -131,12 +137,11 @@ DropOut 以长期可维护性为目标构建。
标准的 GitHub 工作流程适用:
fork → 功能分支 → 拉取请求。
-
## 许可证
+根据 MIT 许可证分发。有关更多信息,请参见 `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)
-
-根据 MIT 许可证分发。有关更多信息,请参见 `LICENSE`。
diff --git a/README.md b/README.md
index e1876ee..6d0eac0 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Drop*O*ut
+English | [中文](README.CN.md)
+
[![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)
@@ -56,7 +58,7 @@ This launcher is built for players who value control, transparency, and long-ter
## Roadmap
-Check our full roadmap at: https://roadmap.sh/r/minecraft-launcher-dev
+Check our full roadmap at: <https://roadmap.sh/r/minecraft-launcher-dev>
- [X] **Account Persistence** — Save login state between sessions
- [X] **Token Refresh** — Auto-refresh expired Microsoft tokens
@@ -100,6 +102,7 @@ Download the latest release for your platform from the [Releases](https://github
git clone https://github.com/HsiangNianian/DropOut.git
cd DropOut
```
+
2. **Install Frontend Dependencies**
```bash
@@ -107,12 +110,14 @@ Download the latest release for your platform from the [Releases](https://github
pnpm install
cd ..
```
+
3. **Run in Development Mode**
```bash
# This will start the frontend server and the Tauri app window
cargo tauri dev
```
+
4. **Build Release Version**
```bash
@@ -125,6 +130,7 @@ Download the latest release for your platform from the [Releases](https://github
DropOut is built with long-term maintainability in mind.
Contributions are welcome, especially in these areas:
+
- Instance system design
- Mod compatibility tooling
- UI/UX improvements
@@ -133,12 +139,11 @@ Contributions are welcome, especially in these areas:
Standard GitHub workflow applies:
fork → feature branch → pull request.
-
## License
+Distributed under the MIT License. See `LICENSE` for more information.
+
[![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/packages/docs/README.md b/packages/docs/README.md
index 63b9290..f4e7563 100644
--- a/packages/docs/README.md
+++ b/packages/docs/README.md
@@ -5,6 +5,7 @@ This is the official documentation site for DropOut Minecraft Launcher, built wi
## Overview
The documentation covers:
+
- **Getting Started**: Installation and first-time setup
- **Features**: Detailed guides for all launcher features
- **Architecture**: Technical design and implementation details
@@ -14,6 +15,7 @@ The documentation covers:
### Multi-language Support
The documentation is available in:
+
- **English** (default) - `content/docs/en/`
- **简体中文** (Simplified Chinese) - `content/docs/zh/`
@@ -68,7 +70,7 @@ pnpm format
## Project Structure
-```
+```bash
packages/docs/
├── content/
│ └── docs/ # Documentation content (MDX)
@@ -97,6 +99,7 @@ packages/docs/
### Structure
Documentation is organized by locale:
+
- English: `content/docs/en/`
- Chinese: `content/docs/zh/`
@@ -105,6 +108,7 @@ Each locale has the same structure with translated content.
### Configuration
i18n is configured in:
+
- `source.config.ts`: Enables i18n support
- `app/lib/source.ts`: Defines available languages and default
@@ -160,6 +164,7 @@ Fumadocs provides several components:
### Translation Guidelines
When translating content:
+
- Keep all code blocks in English
- Translate frontmatter (title, description)
- Keep technical terms (Tauri, Rust, Svelte, etc.) in English
diff --git a/packages/docs/content/zh/getting-started.mdx b/packages/docs/content/zh/getting-started.mdx
index 05d9aa4..d36eaf5 100644
--- a/packages/docs/content/zh/getting-started.mdx
+++ b/packages/docs/content/zh/getting-started.mdx
@@ -111,7 +111,7 @@ chmod +x dropout_*.AppImage
- 内存分配(RAM)
- 窗口分辨率
- Java 路径
-4. 点击**"启动游戏"**
+4. 点击**启动游戏**
5. 在控制台中监视启动过程
## 下一步
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 38ef140..de09c8d 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -54,3 +54,13 @@ inventory = "0.3.21"
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
+
+[target.'cfg(all(windows, target_env = "gnu"))'.build-dependencies]
+embed-resource = "2.4"
+
+[package.metadata.deb]
+depends = "libgtk-3-0"
+section = "games"
+assets = [
+ ["target/release/dropout", "usr/bin/", "755"],
+]
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
index d860e1e..63f98e2 100644
--- a/src-tauri/build.rs
+++ b/src-tauri/build.rs
@@ -1,3 +1,9 @@
fn main() {
+ // For MinGW targets, use embed-resource to generate proper COFF format
+ #[cfg(all(windows, target_env = "gnu"))]
+ {
+ embed_resource::compile("icon.rc", embed_resource::NONE);
+ }
+
tauri_build::build()
}
diff --git a/src-tauri/icon.rc b/src-tauri/icon.rc
new file mode 100644
index 0000000..f5d9048
--- /dev/null
+++ b/src-tauri/icon.rc
@@ -0,0 +1 @@
+1 ICON "icons/icon.ico"
diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs
index dcbd47a..4b92e0a 100644
--- a/src-tauri/src/core/mod.rs
+++ b/src-tauri/src/core/mod.rs
@@ -10,5 +10,6 @@ pub mod instance;
pub mod java;
pub mod manifest;
pub mod maven;
+pub mod modpack;
pub mod rules;
pub mod version_merge;
diff --git a/src-tauri/src/core/modpack.rs b/src-tauri/src/core/modpack.rs
new file mode 100644
index 0000000..5ac9493
--- /dev/null
+++ b/src-tauri/src/core/modpack.rs
@@ -0,0 +1,489 @@
+//! Modpack parsing and extraction module.
+//!
+//! Supported formats:
+//! - Modrinth (.mrpack / zip with `modrinth.index.json`)
+//! - CurseForge (zip with `manifest.json`, manifestType = "minecraftModpack")
+//! - MultiMC / PrismLauncher (zip with `instance.cfg`)
+//!
+//! ## Usage
+//!
+//! ```ignore
+//! // 1. Parse modpack → get metadata + file list + override prefixes
+//! let pack = modpack::import(&path).await?;
+//!
+//! // 2. These can run in parallel for Modrinth/CurseForge:
+//! // a) Extract override files (configs, resource packs, etc.)
+//! modpack::extract_overrides(&path, &game_dir, &pack.override_prefixes, |cur, total, name| {
+//! println!("Extracting ({cur}/{total}) {name}");
+//! })?;
+//! // b) Install Minecraft version — use pack.info.minecraft_version (e.g. "1.20.1")
+//! // → Fetch version manifest, download client jar, assets, libraries.
+//! // c) Install mod loader — use pack.info.mod_loader + mod_loader_version
+//! // → Download loader installer/profile, patch version JSON.
+//!
+//! // 3. Download mod files (use pack.files)
+//! // Each ModpackFile has url, path (relative to game_dir), sha1, size.
+//! // Partial failure is acceptable — missing mods can be retried on next launch.
+//! ```
+
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::fs;
+use std::io::Read;
+use std::path::Path;
+
+type Archive = zip::ZipArchive<fs::File>;
+
+// ── Public types ──────────────────────────────────────────────────────────
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ModpackInfo {
+ pub name: String,
+ pub minecraft_version: Option<String>,
+ pub mod_loader: Option<String>,
+ pub mod_loader_version: Option<String>,
+ pub modpack_type: String,
+ #[serde(default)]
+ pub instance_id: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ModpackFile {
+ pub url: String,
+ pub path: String,
+ pub size: Option<u64>,
+ pub sha1: Option<String>,
+}
+
+/// Unified parse result from any modpack format.
+pub struct ParsedModpack {
+ pub info: ModpackInfo,
+ pub files: Vec<ModpackFile>,
+ pub override_prefixes: Vec<String>,
+}
+
+// ── Public API ────────────────────────────────────────────────────────────
+
+/// Parse a modpack zip and return metadata only (no network, no side effects).
+pub fn detect(path: &Path) -> Result<ModpackInfo, String> {
+ Ok(parse(path)?.info)
+}
+
+/// Parse a modpack zip, resolve download URLs, and return everything needed
+/// to complete the installation.
+pub async fn import(path: &Path) -> Result<ParsedModpack, String> {
+ let mut result = parse(path)?;
+ if result.info.modpack_type == "curseforge" {
+ result.files = resolve_curseforge_files(&result.files).await?;
+ }
+ Ok(result)
+}
+
+/// Extract override files from the modpack zip into the game directory.
+pub fn extract_overrides(
+ path: &Path,
+ game_dir: &Path,
+ override_prefixes: &[String],
+ on_progress: impl Fn(usize, usize, &str),
+) -> Result<(), String> {
+ let file = fs::File::open(path).map_err(|e| format!("Failed to open: {e}"))?;
+ let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid zip: {e}"))?;
+
+ // Collect which prefixes actually exist
+ let all_names: Vec<String> = (0..archive.len())
+ .filter_map(|i| Some(archive.by_index_raw(i).ok()?.name().to_string()))
+ .collect();
+ let prefixes: Vec<&str> = override_prefixes
+ .iter()
+ .filter(|pfx| all_names.iter().any(|n| n.starts_with(pfx.as_str())))
+ .map(|s| s.as_str())
+ .collect();
+
+ let strip = |name: &str| -> Option<String> {
+ prefixes.iter().find_map(|pfx| {
+ let rel = name.strip_prefix(*pfx)?;
+ (!rel.is_empty()).then(|| rel.to_string())
+ })
+ };
+
+ let total = all_names.iter().filter(|n| strip(n).is_some()).count();
+ let mut current = 0;
+
+ for i in 0..archive.len() {
+ let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
+ let name = entry.name().to_string();
+ let Some(relative) = strip(&name) else {
+ continue;
+ };
+
+ let outpath = game_dir.join(&relative);
+ if !outpath.starts_with(game_dir) {
+ continue;
+ } // path traversal guard
+
+ if entry.is_dir() {
+ fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
+ } else {
+ if let Some(p) = outpath.parent() {
+ fs::create_dir_all(p).map_err(|e| e.to_string())?;
+ }
+ let mut f = fs::File::create(&outpath).map_err(|e| e.to_string())?;
+ std::io::copy(&mut entry, &mut f).map_err(|e| e.to_string())?;
+ }
+ current += 1;
+ on_progress(current, total, &relative);
+ }
+ Ok(())
+}
+
+// ── Core parse dispatch ───────────────────────────────────────────────────
+
+type ParserFn = fn(&mut Archive) -> Result<ParsedModpack, String>;
+
+const PARSERS: &[ParserFn] = &[parse_modrinth, parse_curseforge, parse_multimc];
+
+fn parse(path: &Path) -> Result<ParsedModpack, String> {
+ let file = fs::File::open(path).map_err(|e| format!("Failed to open: {e}"))?;
+ let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid zip: {e}"))?;
+
+ for parser in PARSERS {
+ if let Ok(result) = parser(&mut archive) {
+ return Ok(result);
+ }
+ }
+ Ok(ParsedModpack {
+ info: ModpackInfo {
+ name: path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("Imported Modpack")
+ .to_string(),
+ minecraft_version: None,
+ mod_loader: None,
+ mod_loader_version: None,
+ modpack_type: "unknown".into(),
+ instance_id: None,
+ },
+ files: vec![],
+ override_prefixes: vec![],
+ })
+}
+
+// ── Format parsers ────────────────────────────────────────────────────────
+
+fn parse_modrinth(archive: &mut Archive) -> Result<ParsedModpack, String> {
+ let json = read_json(archive, "modrinth.index.json")?;
+ let (mod_loader, mod_loader_version) = parse_modrinth_loader(&json["dependencies"]);
+
+ let files = json["files"]
+ .as_array()
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|f| {
+ if f["env"]["client"].as_str() == Some("unsupported") {
+ return None;
+ }
+ let path = f["path"].as_str()?;
+ if path.contains("..") {
+ return None;
+ }
+ Some(ModpackFile {
+ path: path.to_string(),
+ url: f["downloads"].as_array()?.first()?.as_str()?.to_string(),
+ size: f["fileSize"].as_u64(),
+ sha1: f["hashes"]["sha1"].as_str().map(String::from),
+ })
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+
+ Ok(ParsedModpack {
+ info: ModpackInfo {
+ name: json["name"].as_str().unwrap_or("Modrinth Modpack").into(),
+ minecraft_version: json["dependencies"]["minecraft"].as_str().map(Into::into),
+ mod_loader,
+ mod_loader_version,
+ modpack_type: "modrinth".into(),
+ instance_id: None,
+ },
+ files,
+ override_prefixes: vec!["client-overrides/".into(), "overrides/".into()],
+ })
+}
+
+fn parse_curseforge(archive: &mut Archive) -> Result<ParsedModpack, String> {
+ let json = read_json(archive, "manifest.json")?;
+ if json["manifestType"].as_str() != Some("minecraftModpack") {
+ return Err("not curseforge".into());
+ }
+
+ let (loader, loader_ver) = json["minecraft"]["modLoaders"]
+ .as_array()
+ .and_then(|arr| {
+ arr.iter()
+ .find(|ml| ml["primary"].as_bool() == Some(true))
+ .or_else(|| arr.first())
+ })
+ .and_then(|ml| {
+ let (l, v) = ml["id"].as_str()?.split_once('-')?;
+ Some((Some(l.to_string()), Some(v.to_string())))
+ })
+ .unwrap_or((None, None));
+
+ let files = json["files"]
+ .as_array()
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|f| {
+ Some(ModpackFile {
+ url: format!(
+ "curseforge://{}:{}",
+ f["projectID"].as_u64()?,
+ f["fileID"].as_u64()?
+ ),
+ path: String::new(),
+ size: None,
+ sha1: None,
+ })
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+
+ let overrides = json["overrides"].as_str().unwrap_or("overrides");
+
+ Ok(ParsedModpack {
+ info: ModpackInfo {
+ name: json["name"].as_str().unwrap_or("CurseForge Modpack").into(),
+ minecraft_version: json["minecraft"]["version"].as_str().map(Into::into),
+ mod_loader: loader,
+ mod_loader_version: loader_ver,
+ modpack_type: "curseforge".into(),
+ instance_id: None,
+ },
+ files,
+ override_prefixes: vec![format!("{overrides}/")],
+ })
+}
+
+fn parse_multimc(archive: &mut Archive) -> Result<ParsedModpack, String> {
+ let root = find_multimc_root(archive).ok_or("not multimc")?;
+ let cfg = read_entry(archive, &format!("{root}instance.cfg")).ok_or("not multimc")?;
+
+ let name = cfg_value(&cfg, "name").unwrap_or_else(|| "MultiMC Modpack".into());
+
+ let (mc, loader, loader_ver) = read_json(archive, &format!("{root}mmc-pack.json"))
+ .map(|j| parse_mmc_components(&j))
+ .unwrap_or_default();
+ let mc = mc.or_else(|| cfg_value(&cfg, "IntendedVersion"));
+
+ Ok(ParsedModpack {
+ info: ModpackInfo {
+ name,
+ minecraft_version: mc,
+ mod_loader: loader,
+ mod_loader_version: loader_ver,
+ modpack_type: "multimc".into(),
+ instance_id: None,
+ },
+ files: vec![],
+ override_prefixes: vec![format!("{root}.minecraft/"), format!("{root}minecraft/")],
+ })
+}
+
+// ── CurseForge API resolution ─────────────────────────────────────────────
+
+const CURSEFORGE_API_KEY: &str = env!("CURSEFORGE_API_KEY");
+
+async fn resolve_curseforge_files(files: &[ModpackFile]) -> Result<Vec<ModpackFile>, String> {
+ let file_ids: Vec<u64> = files
+ .iter()
+ .filter_map(|f| {
+ f.url
+ .strip_prefix("curseforge://")?
+ .split(':')
+ .nth(1)?
+ .parse()
+ .ok()
+ })
+ .collect();
+ if file_ids.is_empty() {
+ return Ok(vec![]);
+ }
+
+ let client = reqwest::Client::new();
+
+ // 1. Batch-resolve file metadata
+ let body = cf_post(
+ &client,
+ "/v1/mods/files",
+ &serde_json::json!({ "fileIds": file_ids }),
+ )
+ .await?;
+ let file_arr = body["data"].as_array().cloned().unwrap_or_default();
+
+ // 2. Batch-resolve mod classIds for directory placement
+ let mod_ids: Vec<u64> = file_arr
+ .iter()
+ .filter_map(|f| f["modId"].as_u64())
+ .collect::<std::collections::HashSet<_>>()
+ .into_iter()
+ .collect();
+ let class_map = cf_class_ids(&client, &mod_ids).await;
+
+ // 3. Build results
+ Ok(file_arr
+ .iter()
+ .filter_map(|f| {
+ let name = f["fileName"].as_str()?;
+ let id = f["id"].as_u64()?;
+ let url = f["downloadUrl"]
+ .as_str()
+ .map(String::from)
+ .unwrap_or_else(|| {
+ format!(
+ "https://edge.forgecdn.net/files/{}/{}/{name}",
+ id / 1000,
+ id % 1000
+ )
+ });
+ let dir = match f["modId"].as_u64().and_then(|mid| class_map.get(&mid)) {
+ Some(12) => "resourcepacks",
+ Some(6552) => "shaderpacks",
+ _ => "mods",
+ };
+ Some(ModpackFile {
+ url,
+ path: format!("{dir}/{name}"),
+ size: f["fileLength"].as_u64(),
+ sha1: None,
+ })
+ })
+ .collect())
+}
+
+async fn cf_post(
+ client: &reqwest::Client,
+ endpoint: &str,
+ body: &serde_json::Value,
+) -> Result<serde_json::Value, String> {
+ let resp = client
+ .post(format!("https://api.curseforge.com{endpoint}"))
+ .header("x-api-key", CURSEFORGE_API_KEY)
+ .json(body)
+ .send()
+ .await
+ .map_err(|e| format!("CurseForge API error: {e}"))?;
+ if !resp.status().is_success() {
+ return Err(format!("CurseForge API returned {}", resp.status()));
+ }
+ resp.json().await.map_err(|e| e.to_string())
+}
+
+async fn cf_class_ids(client: &reqwest::Client, mod_ids: &[u64]) -> HashMap<u64, u64> {
+ if mod_ids.is_empty() {
+ return Default::default();
+ }
+ let Ok(body) = cf_post(
+ client,
+ "/v1/mods",
+ &serde_json::json!({ "modIds": mod_ids }),
+ )
+ .await
+ else {
+ return Default::default();
+ };
+ body["data"]
+ .as_array()
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|m| Some((m["id"].as_u64()?, m["classId"].as_u64()?)))
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────
+
+fn read_entry(archive: &mut Archive, name: &str) -> Option<String> {
+ let mut buf = String::new();
+ archive.by_name(name).ok()?.read_to_string(&mut buf).ok()?;
+ Some(buf)
+}
+
+fn read_json(archive: &mut Archive, name: &str) -> Result<serde_json::Value, String> {
+ let content = read_entry(archive, name).ok_or_else(|| format!("{name} not found"))?;
+ serde_json::from_str(&content).map_err(|e| e.to_string())
+}
+
+fn cfg_value(content: &str, key: &str) -> Option<String> {
+ let prefix = format!("{key}=");
+ content
+ .lines()
+ .find_map(|l| Some(l.strip_prefix(&prefix)?.trim().to_string()))
+}
+
+fn find_multimc_root(archive: &mut Archive) -> Option<String> {
+ for i in 0..archive.len() {
+ let name = archive.by_index_raw(i).ok()?.name().to_string();
+ if name == "instance.cfg" {
+ return Some(String::new());
+ }
+ if name.ends_with("/instance.cfg") && name.matches('/').count() == 1 {
+ return Some(name.strip_suffix("instance.cfg")?.to_string());
+ }
+ }
+ None
+}
+
+fn parse_modrinth_loader(deps: &serde_json::Value) -> (Option<String>, Option<String>) {
+ const LOADERS: &[(&str, &str)] = &[
+ ("fabric-loader", "fabric"),
+ ("forge", "forge"),
+ ("quilt-loader", "quilt"),
+ ("neoforge", "neoforge"),
+ ("neo-forge", "neoforge"),
+ ];
+ LOADERS
+ .iter()
+ .find_map(|(key, name)| {
+ let v = deps[*key].as_str()?;
+ Some((Some((*name).into()), Some(v.into())))
+ })
+ .unwrap_or((None, None))
+}
+
+fn parse_mmc_components(
+ json: &serde_json::Value,
+) -> (Option<String>, Option<String>, Option<String>) {
+ let (mut mc, mut loader, mut loader_ver) = (None, None, None);
+ for c in json["components"].as_array().into_iter().flatten() {
+ let ver = c["version"].as_str().map(String::from);
+ match c["uid"].as_str().unwrap_or("") {
+ "net.minecraft" => mc = ver,
+ "net.minecraftforge" => {
+ loader = Some("forge".into());
+ loader_ver = ver;
+ }
+ "net.neoforged" => {
+ loader = Some("neoforge".into());
+ loader_ver = ver;
+ }
+ "net.fabricmc.fabric-loader" => {
+ loader = Some("fabric".into());
+ loader_ver = ver;
+ }
+ "org.quiltmc.quilt-loader" => {
+ loader = Some("quilt".into());
+ loader_ver = ver;
+ }
+ "com.mumfrey.liteloader" => {
+ loader = Some("liteloader".into());
+ loader_ver = ver;
+ }
+ _ => {}
+ }
+ }
+ (mc, loader, loader_ver)
+}