Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,63 @@ it does everything you'd expect from a launcher.

Browse Modrinth and CurseForge modpacks or import Modrinth, CurseForge, and MultiMC/Prism archives (including GTNH packs in that format). Direct imports accept a local archive, Modrinth URL, or Modrinth project slug. CurseForge requires a build-time [API key](#curseforge-api). Multiple Microsoft accounts and offline accounts are supported.

### exporting modpacks

Press `e` on an instance to open the TUI exporter; its file scan starts immediately.
Unchanged files reuse hashes from the content index; writing the archive verifies them again.
Use `j`/`k` or the arrow keys to select a row, `h`/`l` to cycle formats, and `Enter`
to edit metadata. The format row shows the archive extension; MultiMC/Prism also
shows a **Bundled** badge.

Author defaults to the system username. Metadata unsupported by the chosen
format stays disabled, so changing formats does not resize the form.

Once scanning finishes, open **Included files** to browse the game folder. Default pack files start
checked; worlds, logs and personal settings start unchecked. Use `h`/`l` to move
between folders and `Space` to select or deselect a file or a whole folder. `h`
stops at the root; only `Esc` returns to the form. The selection summary appears
beside the files, or above them in narrow terminals. It separates download links,
ordinary file copies (such as configs and shaders), and unmatched content. Links
are downloaded on import; file copies are stored inside the archive. Unmatched
files and their containing folders use yellow names, even when unchecked.
Press `c` to allow copying those files after checking their redistribution licences. Press `Enter` for
the final export summary, then `Enter` again to write the archive. Existing files
require one additional overwrite confirmation. The popup closes when writing
starts; progress appears in Overview and errors appear as toasts, without a
completion popup. `Esc` goes back without exporting. Symlinks, disabled files and
unsafe paths are not selectable.

The CLI supports the same formats:

```sh
rmcl export "My Instance" --format curseforge --output pack.zip
rmcl export "My Instance" --format mrpack --output pack.mrpack
rmcl export "My Instance" --format mmc --output prism-pack.zip
```

`curseforge` is the default. MultiMC/Prism exports bundle selected files and work
offline. CurseForge and Modrinth exports identify content through their APIs and
reference exact matching files. Unmatched content requires explicit approval:
`--bundle-unmatched` in the CLI or the checkbox in the TUI file browser. Only bundle
files you have permission to redistribute. Author metadata is written for
CurseForge; the other formats do not have an equivalent author field.
Modrinth exports target clients; server compatibility is not inferred from an
installed client instance.

Exports include enabled mods, configs, scripts, resource packs, shaders and the
top-level datapacks folder. Worlds, logs and personal settings are excluded by
default. Add files or folders with repeatable `--include` arguments, for example
`--include options.txt --include saves/MyWorld`. Symlinks and non-portable paths
are rejected. The CLI refuses existing output files; the TUI replaces them only
after confirmation and successful archive creation. Output must be outside the game folder.

For a standalone folder, use `--directory` instead of an instance name:

```sh
rmcl export --directory ./minecraft --minecraft 1.21.1 --loader fabric \
--loader-version 0.16.14 --format mrpack --output pack.mrpack
```

---

## navigation
Expand All @@ -53,6 +110,11 @@ Use `Ctrl` + arrow keys to move between the Instances, Content, Accounts,
Settings, and Overview panels. In Content, use plain `Left` / `Right` or `h` / `l`
to switch tabs, and `Tab` to switch between installed content and discovery.

Content can be installed, updated, removed, or enabled/disabled while a game is
running. Mods normally apply on the next launch; packs and configs may reload
live, and the operating system can still reject changes to open files. Instance
deletion, renaming, world deletion/renaming, runtime changes, and whole-modpack replacement require stopping it.

## authentication

rmcl uses its own Microsoft client ID for Minecraft account authentication.
Expand Down
65 changes: 54 additions & 11 deletions src/cli/export.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command};

use crate::instance::InstanceManager;
use crate::instance::export::{ExportOptions, export_curseforge};
use crate::instance::export::{
ExportOptions, PackFormat, prepare_export, prepare_instance_export, validate_output,
write_export,
};

pub fn command() -> Command {
Command::new("export")
.about("Export a CurseForge-compatible modpack ZIP")
.about("Export a CurseForge, Modrinth, or MultiMC/Prism modpack")
.arg(
Arg::new("format")
.long("format")
.value_parser(["curseforge", "mrpack", "mmc"])
.default_value("curseforge"),
)
.arg(Arg::new("instance").help("Instance name"))
.arg(
Arg::new("directory")
Expand Down Expand Up @@ -48,12 +57,12 @@ pub fn command() -> Command {
Arg::new("bundle-unmatched")
.long("bundle-unmatched")
.action(ArgAction::SetTrue)
.help("Bundle custom/unmatched mods in overrides"),
.help("Allow bundling custom/unmatched content (check redistribution permissions)"),
)
}

pub async fn handle_export(matches: &ArgMatches) -> Result<(), Box<dyn std::error::Error>> {
let mut lock = None;
let mut instance_manager = None;
let (root, name, game_version, loader, loader_version) = if let Some(directory) =
matches.get_one::<String>("directory")
{
Expand Down Expand Up @@ -83,20 +92,23 @@ pub async fn handle_export(matches: &ArgMatches) -> Result<(), Box<dyn std::erro
settings.paths.resolve_meta_dir(),
);
drop(settings);
lock = Some(crate::instance::runtime::lock_instance(
&manager.instances_dir,
name,
)?);
let config = manager.load_one(name)?;
let root = crate::storage::InstancePaths::new(manager.instances_dir.join(name)).minecraft();
instance_manager = Some(manager);
(
crate::storage::InstancePaths::new(manager.instances_dir.join(name)).minecraft(),
root,
config.name,
config.game_version,
config.loader,
config.loader_version,
)
};
let options = ExportOptions {
format: match super::utils::required_arg(matches, "format")? {
"mrpack" => PackFormat::Mrpack,
"mmc" => PackFormat::Mmc,
_ => PackFormat::CurseForge,
},
name: matches.get_one::<String>("name").cloned().unwrap_or(name),
version: super::utils::required_arg(matches, "version")?.to_owned(),
author: super::utils::required_arg(matches, "author")?.to_owned(),
Expand All @@ -113,14 +125,45 @@ pub async fn handle_export(matches: &ArgMatches) -> Result<(), Box<dyn std::erro
};
let output =
crate::config::settings::resolve_path(super::utils::required_arg(matches, "output")?);
validate_output(&root, &output).map_err(|error| error.to_string())?;
println!(
"Exporting '{}': identifying mods and packaging selected files...",
options.name
);
export_curseforge(&root, &output, &options)
let plan = if let Some(manager) = instance_manager {
prepare_instance_export(
&manager,
super::utils::required_arg(matches, "instance")?,
&options,
)
.await
} else {
prepare_export(&root, &options).await
}
.map_err(|error| error.to_string())?;
if !options.bundle_unmatched && !plan.unmatched.is_empty() {
eprintln!(
"To allow bundling unmatched content, use --bundle-unmatched after checking redistribution permissions."
);
}
let report = write_export(plan, &output)
.await
.map_err(|error| error.to_string())?;
drop(lock);
if !report.unmatched.is_empty() {
eprintln!(
"Bundled unmatched content:\n{}",
report
.unmatched
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join("\n")
);
}
println!("Exported '{}' to {}", options.name, output.display());
println!(
"{} referenced files, {} bundled files",
report.referenced_count, report.bundled_count
);
Ok(())
}
26 changes: 26 additions & 0 deletions src/cli/tests/arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,29 @@ fn parses_instance_list_subcommand() {
"instance list should resolve to the list subcommand"
);
}

#[test]
fn exports_accept_each_format_and_preserve_curseforge_default() {
for format in [None, Some("curseforge"), Some("mrpack"), Some("mmc")] {
let mut args = vec!["rmcl", "export", "Example", "--output", "pack.zip"];
if let Some(format) = format {
args.extend(["--format", format]);
}
let matches = build_command().try_get_matches_from(args).unwrap();
assert_eq!(
matches
.subcommand_matches("export")
.unwrap()
.get_one::<String>("format")
.map(String::as_str),
Some(format.unwrap_or("curseforge"))
);
}
assert!(
build_command()
.try_get_matches_from([
"rmcl", "export", "Example", "--output", "pack.zip", "--format", "unknown"
])
.is_err()
);
}
17 changes: 13 additions & 4 deletions src/instance/content/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ pub(crate) fn relative_path(minecraft_dir: &Path, path: &Path) -> io::Result<Pat
Ok(relative.to_owned())
}

fn mutation_lock(manifest_path: &Path, relative: &Path) -> io::Result<ContentLock> {
if relative.starts_with("saves") && relative.components().count() <= 2 {
ContentLock::acquire_stopped(manifest_path)
} else {
ContentLock::acquire(manifest_path)
}
.map_err(io::Error::other)
}

pub(crate) fn validate_record(minecraft_dir: &Path, record: &ContentFileRecord) -> io::Result<()> {
let path = minecraft_dir.join(&record.relative_path);
relative_path(minecraft_dir, &path)?;
Expand Down Expand Up @@ -282,13 +291,13 @@ pub(crate) fn toggle(entry: &ContentEntry) -> io::Result<Option<PathBuf>> {
} else {
minecraft_dir.join(".rmcl-content.json")
};
let lock = ContentLock::acquire(&manifest_path).map_err(io::Error::other)?;
let relative = relative_path(minecraft_dir, &entry.path)?;
let lock = mutation_lock(&manifest_path, &relative)?;
let mut manifest = if instance_minecraft.is_some() {
lock.load().map_err(io::Error::other)?
} else {
ContentManifest::default()
};
let relative = relative_path(minecraft_dir, &entry.path)?;
let new_relative = relative_path(minecraft_dir, &new_path)?;
require_absent(&new_path)?;
if manifest.record(&new_relative).is_some() {
Expand Down Expand Up @@ -386,10 +395,10 @@ pub(crate) fn remove(
expected: &[ContentFileRecord],
orphan_only: bool,
) -> io::Result<(ContentManifest, Vec<PathBuf>)> {
let lock = ContentLock::acquire(manifest_path).map_err(io::Error::other)?;
let relative = relative_path(minecraft_dir, path)?;
let lock = mutation_lock(manifest_path, &relative)?;
let mut manifest = lock.load().map_err(io::Error::other)?;
let previous = manifest.clone();
let relative = relative_path(minecraft_dir, path)?;
if manifest
.files
.iter()
Expand Down
65 changes: 55 additions & 10 deletions src/instance/content/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ pub(crate) struct ContentLock {

impl ContentLock {
pub(crate) fn acquire(path: &Path) -> Result<Self, ManifestError> {
Self::acquire_with_instance_guard(path, false)
}

pub(crate) fn acquire_stopped(path: &Path) -> Result<Self, ManifestError> {
Self::acquire_with_instance_guard(path, true)
}

fn acquire_with_instance_guard(
path: &Path,
require_stopped: bool,
) -> Result<Self, ManifestError> {
let parent = path.parent().ok_or_else(|| {
ManifestError::InvalidPath(format!("{} has no parent", path.display()))
})?;
Expand All @@ -43,17 +54,37 @@ impl ContentLock {
.ok_or_else(|| {
ManifestError::InvalidPath(format!("{} has no instance name", path.display()))
})?;
Some(crate::instance::runtime::lock_instance(root, name).map_err(
|error| match error {
crate::instance::manager::InstanceError::InstanceRunning(_) => {
ManifestError::Busy(path.to_owned())
}
error => ManifestError::Io(std::io::Error::other(error)),
},
)?)
let lock = if require_stopped {
crate::instance::runtime::lock_instance(root, name)
} else {
crate::instance::runtime::lock_content(root, name)
};
Some(lock.map_err(|error| match error {
crate::instance::manager::InstanceError::InstanceRunning(_) => {
ManifestError::Busy(path.to_owned())
}
error => ManifestError::Io(std::io::Error::other(error)),
})?)
} else {
None
};
Self::acquire_file(path, instance_lock)
}

pub(crate) fn acquire_with_held_instance_lock(
path: &Path,
_guard: &crate::instance::runtime::InstanceLock,
) -> Result<Self, ManifestError> {
Self::acquire_file(path, None)
}

fn acquire_file(
path: &Path,
instance_lock: Option<crate::instance::runtime::InstanceLock>,
) -> Result<Self, ManifestError> {
let parent = path.parent().ok_or_else(|| {
ManifestError::InvalidPath(format!("{} has no parent", path.display()))
})?;
std::fs::create_dir_all(parent)?;
let file = OpenOptions::new()
.read(true)
Expand Down Expand Up @@ -468,6 +499,14 @@ pub enum ManifestError {
}

pub fn fingerprint(path: &Path) -> Result<FileFingerprint, std::io::Error> {
fingerprint_with_check(path, || Ok(()))
}

pub(crate) fn fingerprint_with_check(
path: &Path,
check: impl Fn() -> std::io::Result<()>,
) -> Result<FileFingerprint, std::io::Error> {
check()?;
let metadata = std::fs::metadata(path)?;
let modified_ns = metadata
.modified()
Expand All @@ -481,6 +520,7 @@ pub fn fingerprint(path: &Path) -> Result<FileFingerprint, std::io::Error> {
let mut curseforge_len = 0u32;
let mut buffer = [0u8; 64 * 1024];
loop {
check()?;
let read = file.read(&mut buffer)?;
if read == 0 {
break;
Expand All @@ -500,7 +540,7 @@ pub fn fingerprint(path: &Path) -> Result<FileFingerprint, std::io::Error> {
("sha512".to_owned(), format!("{:x}", sha512.finalize())),
(
"curseforge".to_owned(),
curseforge_fingerprint(&mut file, curseforge_len)?.to_string(),
curseforge_fingerprint(&mut file, curseforge_len, &check)?.to_string(),
),
]);
Ok(FileFingerprint {
Expand All @@ -510,13 +550,18 @@ pub fn fingerprint(path: &Path) -> Result<FileFingerprint, std::io::Error> {
})
}

fn curseforge_fingerprint(reader: &mut impl Read, length: u32) -> std::io::Result<u32> {
fn curseforge_fingerprint(
reader: &mut impl Read,
length: u32,
check: &impl Fn() -> std::io::Result<()>,
) -> std::io::Result<u32> {
const M: u32 = 0x5bd1e995;
let mut hash = 1 ^ length;
let mut block = [0u8; 4];
let mut block_len = 0;
let mut buffer = [0u8; 64 * 1024];
loop {
check()?;
let read = reader.read(&mut buffer)?;
if read == 0 {
break;
Expand Down
Loading
Loading