Skip to content

feat(instance): export modpack#1344

Open
w1049 wants to merge 1 commit intomainfrom
w1049/modpack
Open

feat(instance): export modpack#1344
w1049 wants to merge 1 commit intomainfrom
w1049/modpack

Conversation

@w1049
Copy link
Collaborator

@w1049 w1049 commented Jan 29, 2026

Checklist

  • Changes have been tested locally and work as expected.
  • All tests in workflows pass successfully.
  • Documentation has been updated if necessary.
  • Code formatting and commit messages align with the project's conventions.
  • Comments have been added for any complex logic or functionality if possible.

This PR is a ..

  • 🆕 New feature
  • 🐞 Bug fix
  • 🛠 Refactoring
  • ⚡️ Performance improvement
  • 🌐 Internationalization
  • 📄 Documentation improvement
  • 🎨 Code style optimization
  • ❓ Other (Please specify below)

Related Issues

#1082

Description

支持的整合包类型:

  • Modrinth
  • MultiMC(未测试)

目前性能似乎有些问题。

Additional Context

  • Add any other relevant information or screenshots here.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements instance modpack export (Modrinth + MultiMC) end-to-end, addressing #1082 by wiring a new export modal to new Tauri backend commands and ZIP creation utilities.

Changes:

  • Add frontend export modal UI, translations, and service methods for exporting and listing exportable files.
  • Add backend modpack export pipeline: file listing, manifest generation, remote-file detection, and async ZIP writing.
  • Register new Tauri commands and add required Rust dependencies (walkdir, async_zip).

Reviewed changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
src/services/instance.ts Adds exportModpack / listModpackFiles service calls.
src/models/instance/misc.ts Adds ModpackFileList type for exportable file list payload.
src/locales/zh-Hans.json Adds Export Modpack modal strings + General.optional.
src/locales/en.json Adds Export Modpack modal strings + General.optional.
src/layouts/instance-details-layout.tsx Wires “Export Modpack” menu action to open the new shared modal.
src/components/special/shared-modals-provider.tsx Registers the new export-modpack shared modal.
src/components/modals/export-modpack-modal.tsx New modal UI for selecting export format/options and included files.
src-tauri/src/utils/fs.rs Adds async ZIP writer helper for modpack export packaging.
src-tauri/src/resource/helpers/modrinth/mod.rs Switches local file reads to async tokio::fs::read.
src-tauri/src/resource/helpers/curseforge/mod.rs Switches local file reads to async tokio::fs::read.
src-tauri/src/lib.rs Registers list_modpack_files / export_modpack commands.
src-tauri/src/instance/models/misc.rs Adds backend ModpackFileList struct.
src-tauri/src/instance/helpers/modpack/modrinth.rs Updates Modrinth manifest structs/defaults for export support.
src-tauri/src/instance/helpers/modpack/mod.rs Exposes new export helper module.
src-tauri/src/instance/helpers/modpack/export.rs New core backend export logic (listing, manifest generation, remote file collection).
src-tauri/src/instance/commands.rs Adds new Tauri commands for listing/exporting modpacks.
src-tauri/Cargo.toml Adds dependencies required for export implementation.
src-tauri/Cargo.lock Locks new dependency graph for export implementation.

Comment on lines +1369 to +1373
for rel in files {
let full = base_path.join(&rel);
if tokio::fs::try_exists(&full).await.unwrap_or(false) {
selected_files.push((rel, full));
}
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

files comes from the frontend and is joined directly onto base_path. If a caller passes an absolute path or a path containing .., base_path.join(rel) can escape the instance directory and end up zipping arbitrary files from the filesystem. Validate that each rel is a safe relative path (no root/drive prefix, no .. components), and ensure the resolved path stays within base_path (e.g., via canonicalize + starts_with) before adding it to selected_files and before using it as a ZIP entry name.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selected_files 是前端从 file list 中选出的,前端应当保证正确

Comment on lines +484 to +514
let mut tasks = Vec::new();
let semaphore = Arc::new(Semaphore::new(
std::thread::available_parallelism().unwrap().into(),
));

for (rel, full) in selected_files {
let rel = rel.clone();
let full = full.clone();
let app = app.clone();
let permit = semaphore
.clone()
.acquire_owned()
.await
.map_err(|_| InstanceError::SemaphoreAcquireFailed)?;

let task = tokio::spawn(async move {
let result = if is_remote_candidate(&rel) && !no_create_remote_files {
build_modrinth_remote_file(&app, &rel, &full, skip_curseforge)
.await
.ok()
.flatten()
} else {
None
};

drop(permit);
(rel, full, result)
});

tasks.push(task);
}
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collect_modrinth_files spawns one Tokio task per selected file and stores every JoinHandle in tasks. With large instances this can create thousands of tasks and increase memory/CPU overhead even though concurrency is gated by a semaphore. Consider using a bounded pattern (e.g., futures::stream::iter(...).buffer_unordered(limit) or tokio::task::JoinSet with a fixed number of in-flight tasks) to avoid unbounded task creation.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以考虑,不过目前其他地方也是这样写的

inputProps={{ fontSize: "xs-sm" }}
formErrMsgProps={{ fontSize: "xs-sm" }}
checkError={checkVersionError}
localeKey="ExportModpackModal.errorMessage"
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Editable looks up validation messages via t(${localeKey}.error-${code}), but the new ExportModpackModal.errorMessage entries in the locale JSON are keyed as "1"/"2"/"3" (not "error-1", etc.), so the inline validation message won’t render. Also, modpackVersion reuses the same localeKey/error codes as the name field, which would display the wrong message once the keys are fixed—consider a separate errorMessageVersion (or distinct codes/messages).

Suggested change
localeKey="ExportModpackModal.errorMessage"
localeKey="ExportModpackModal.errorMessageVersion"

Copilot uses AI. Check for mistakes.
Comment on lines +248 to +264
const parsedMinMemory = minMemoryInput.trim()
? Number.parseInt(minMemoryInput.trim(), 10)
: undefined;

const options: ExportModpackOptions = {
format: exportFormat,
name: modpackName,
version: modpackVersion,
author: author || undefined,
description: description || undefined,
packWithLauncher: packWithLauncher || undefined,
minMemory: Number.isNaN(parsedMinMemory || NaN)
? undefined
: parsedMinMemory,
noCreateRemoteFiles: noCreateRemoteFiles || undefined,
skipCurseForgeRemoteFiles: skipCurseForgeRemoteFiles || undefined,
};
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minMemory is deserialized as Option<u32> on the Rust side; if the user enters a negative value (e.g. -1), parseInt will produce a number and the frontend will send it, causing the command payload to fail deserialization. Add validation to only allow non-negative integers (and ideally a reasonable range) before setting minMemory in options (or change the backend type and validate there).

Copilot uses AI. Check for mistakes.
Comment on lines +1236 to +1238
"1": "Name cannot be empty",
"2": "Name contains invalid characters",
"3": "Name is too long (max 255 characters)"
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Editable expects validation keys in the form error-<code> (e.g. error-1) under the provided localeKey, but this section is keyed as "1"/"2"/"3". As a result, the inline validation message for the export modal won’t resolve. Rename these keys to error-1, error-2, error-3 (and consider adding separate messages for version validation if needed).

Suggested change
"1": "Name cannot be empty",
"2": "Name contains invalid characters",
"3": "Name is too long (max 255 characters)"
"error-1": "Name cannot be empty",
"error-2": "Name contains invalid characters",
"error-3": "Name is too long (max 255 characters)"

Copilot uses AI. Check for mistakes.
Comment on lines +520 to +528
if let Ok(res) = task.await {
match res {
(_, _, Some(modrinth_file)) => {
modrinth_files.push(modrinth_file);
}
(rel, full, None) => {
override_files.push((rel.to_string(), full));
}
}
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Join errors from tokio::spawn are currently ignored (if let Ok(res) = task.await). If any task panics/cancels, the corresponding file will be silently dropped from both modrinth_files and override_files, producing an incomplete export without an error. Propagate the JoinError (or convert it into an SJMCLResult error) so export fails loudly.

Suggested change
if let Ok(res) = task.await {
match res {
(_, _, Some(modrinth_file)) => {
modrinth_files.push(modrinth_file);
}
(rel, full, None) => {
override_files.push((rel.to_string(), full));
}
}
let res = task
.await
.map_err(|_| InstanceError::SemaphoreAcquireFailed)?;
match res {
(_, _, Some(modrinth_file)) => {
modrinth_files.push(modrinth_file);
}
(rel, full, None) => {
override_files.push((rel.to_string(), full));
}

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

应该修改为:task 失败则进入 override_files

Comment on lines +266 to +271
const response = await InstanceService.exportModpack(
instanceId,
savePath,
options,
selectedFileList
);
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modal currently allows exporting with selectedFileList.length === 0. In that case the backend returns a generic ModpackManifestParseError, but the UI already has a localized error string (ExportModpackModal.error.noCategorySelected). Add a frontend guard before invoking exportModpack to show a clear message when no files are selected.

Copilot uses AI. Check for mistakes.
Comment on lines +315 to +321
const FileTreeItem: React.FC<{ node: FileTreeNode; depth?: number }> = ({
node,
depth = 0,
}) => {
const expanded = expandedPaths.has(node.path);
const leafPaths = useMemo(() => collectLeafPaths(node), [node]);
const selectedCount = useMemo(
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FileTreeItem is declared inside ExportModpackModal, which recreates the component type on every parent render. That forces React to treat all tree nodes as new components (remounting) and can noticeably hurt performance for large file lists. Move FileTreeItem to module scope (and optionally wrap with React.memo) and pass the needed state/handlers as props.

Copilot uses AI. Check for mistakes.
Comment on lines +1236 to +1238
"1": "名称不能为空",
"2": "名称包含无效字符",
"3": "名称过长(最多 255 个字符)"
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Editable expects validation keys in the form error-<code> (e.g. error-1) under the provided localeKey, but this section is keyed as "1"/"2"/"3". As a result, the inline validation message for the export modal won’t resolve. Rename these keys to error-1, error-2, error-3 (and consider adding separate messages for version validation if needed).

Suggested change
"1": "名称不能为空",
"2": "名称包含无效字符",
"3": "名称过长(最多 255 个字符)"
"error-1": "名称不能为空",
"error-2": "名称包含无效字符",
"error-3": "名称过长(最多 255 个字符)"

Copilot uses AI. Check for mistakes.
Comment on lines +485 to +487
let semaphore = Arc::new(Semaphore::new(
std::thread::available_parallelism().unwrap().into(),
));
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

available_parallelism().unwrap() can panic (e.g. if the OS can’t report CPU count). Since this runs in a Tauri command, a panic would crash the backend. Prefer a safe fallback like available_parallelism().map(|n| n.get()).unwrap_or(4) when sizing the semaphore.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

同上面的并行问题,目前与其他地方保持一致

@w1049 w1049 requested review from Reqwey and UNIkeEN January 30, 2026 06:17
@UNIkeEN UNIkeEN requested review from SundayChen, hans362, pangbo13 and xunying123 and removed request for Reqwey February 2, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments