feat: instances v2 (#6431)

* feat: base of instances v2

* feat: use old profiles with compat layer

* prototype: instances v2

* fix: install_from using profile

* fix: skins migration fix

* fix: frontend still using profile path

* fix: add update proj multiselect guard

* fix: cargo fmt

* fix: content missing fields

* feat: break up app-lib/api/instance.rs

* fix: check_content_updates mismatch

* fix: updater modal cleanup w/new structure

* feat: better update all handling

* fix: remove preview_update_all

* fix: feedback on bulk update + lint

* fix: rem transitions

* fix: change to jsonb

* feat: app db backup after update

* fix: lint

* fix: sqlx prepare + use sqlx macros

* fix: lint

* fix: bugs

* feat: defuck the installing process up

* fix: bug of hell

* fix: shear

* fix: fmt

* fix: install progress spacing + change mc/content/overrides to bytes

* fix: lint

* fix: prepr

* fix: navtabs anim not working in app

* fix: worlds.vue improvements + browse page fixes

* feat: optimise queries + adapter fns

* fix: lint

* fix: lint

* feat: shared modrinth-content-management crate (#6469)

* feat: disable warnings setting

* feat: add instances shortcuts (#6329)

* Add modrinth://launch deep link to start a profile

Support external profile launching via modrinth://launch/{profile_path} for integrations such as Stream Deck.

* Change route to /launch/profile/{id} for future extensibility

* fix: ensure profile path is url decoded

* fix: URL-decode profile path from deep link

* fix: use urlencoding crate for URL decoding

* feat: implement app instance shortcuts

* feat: change windows shortcut creation to use windows api instead

* feat: implement creating a shortcut launching world/server

* format

* fmt

* fix multiline inline tables

* pnpm prepr

* feat: move create shortcut to last item

* refactor: split up shortcuts.rs for individual platforms

* refactor: turn profile launch url into url type

* use string literal and add safety comment

* pt2

* refactor: rename anything that's profile into instance

* update mac shortcut

---------

Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>

---------

Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-25 21:19:29 +00:00
committed by GitHub
co-authored by DJCheesusReal Truman Gao
parent ef4044534f
commit 734720e11e
353 changed files with 24745 additions and 9771 deletions
@@ -0,0 +1,295 @@
use super::content::get_projects;
use super::get::get;
use super::paths::get_full_path;
use crate::event::LoadingBarType;
use crate::event::emit::{emit_loading, init_loading};
use crate::pack::install_from::{
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
};
use crate::state::{
CacheBehaviour, CachedEntry, InstanceMetadata, ModLoader, SideType, State,
};
use crate::util::io::{self, IOError};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use path_util::SafeRelativeUtf8UnixPathBuf;
use std::collections::{HashMap, HashSet};
use std::iter::FromIterator;
use std::path::PathBuf;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
#[tracing::instrument(skip_all)]
pub async fn export_mrpack(
instance_id: &str,
export_path: PathBuf,
included_export_candidates: Vec<String>,
version_id: Option<String>,
description: Option<String>,
_name: Option<String>,
) -> crate::Result<()> {
let state = State::get().await?;
let _permit: tokio::sync::SemaphorePermit =
state.io_semaphore.0.acquire().await?;
let metadata = get(instance_id).await?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to export a nonexistent instance {instance_id}!"
))
})?;
let included_export_candidates = included_export_candidates
.into_iter()
.filter(|x| {
if let Some(f) = PathBuf::from(x).file_name()
&& f.to_string_lossy().starts_with(".DS_Store")
{
return false;
}
true
})
.collect::<Vec<_>>();
let instance_base_path = get_full_path(instance_id).await?;
let mut file = File::create(&export_path)
.await
.map_err(|e| IOError::with_path(e, &export_path))?;
let mut writer = ZipFileWriter::with_tokio(&mut file);
let version_id = version_id.unwrap_or("1.0.0".to_string());
let mut packfile =
create_mrpack_json(&metadata, version_id, description).await?;
let included_candidates_set = HashSet::<_>::from_iter(
included_export_candidates.iter().map(|x| x.as_str()),
);
packfile
.files
.retain(|f| included_candidates_set.contains(f.path.as_str()));
let mut path_list = Vec::new();
add_all_recursive_folder_paths(&instance_base_path, &mut path_list).await?;
let loading_bar = init_loading(
LoadingBarType::ZipExtract {
instance_id: metadata.instance.id.clone(),
instance_name: metadata.instance.name.clone(),
},
path_list.len() as f64,
"Exporting instance to .mrpack",
)
.await?;
for path in path_list {
emit_loading(&loading_bar, 1.0, None)?;
let relative_path = pack_get_relative_path(&instance_base_path, &path)?;
if packfile.files.iter().any(|f| f.path == relative_path)
|| !included_candidates_set
.iter()
.any(|x| relative_path.starts_with(&**x))
{
continue;
}
if path.is_file() {
let mut file = File::open(&path)
.await
.map_err(|e| IOError::with_path(e, &path))?;
let mut data = Vec::new();
file.read_to_end(&mut data).await.map_err(IOError::from)?;
let builder = ZipEntryBuilder::new(
format!("overrides/{relative_path}").into(),
Compression::Deflate,
);
writer.write_entry_whole(builder, &data).await?;
}
}
let data = serde_json::to_vec_pretty(&packfile)?;
let builder = ZipEntryBuilder::new(
"modrinth.index.json".to_string().into(),
Compression::Deflate,
);
writer.write_entry_whole(builder, &data).await?;
writer.close().await?;
Ok(())
}
#[tracing::instrument]
pub async fn get_pack_export_candidates(
instance_id: &str,
) -> crate::Result<Vec<SafeRelativeUtf8UnixPathBuf>> {
let mut path_list = Vec::new();
let instance_base_dir = get_full_path(instance_id).await?;
let mut read_dir = io::read_dir(&instance_base_dir).await?;
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|e| IOError::with_path(e, &instance_base_dir))?
{
let path = entry.path();
if path.is_dir() {
let mut read_dir = io::read_dir(&path).await?;
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|e| IOError::with_path(e, &instance_base_dir))?
{
path_list.push(pack_get_relative_path(
&instance_base_dir,
&entry.path(),
)?);
}
} else {
path_list.push(pack_get_relative_path(&instance_base_dir, &path)?);
}
}
Ok(path_list)
}
fn pack_get_relative_path(
instance_path: &PathBuf,
path: &PathBuf,
) -> crate::Result<SafeRelativeUtf8UnixPathBuf> {
Ok(SafeRelativeUtf8UnixPathBuf::try_from(
path.strip_prefix(instance_path)
.map_err(|_| {
crate::ErrorKind::FSError(format!(
"Path {path:?} does not correspond to an instance"
))
})?
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/"),
)?)
}
#[tracing::instrument(skip_all)]
pub async fn create_mrpack_json(
metadata: &InstanceMetadata,
version_id: String,
description: Option<String>,
) -> crate::Result<PackFormat> {
let mut dependencies = HashMap::new();
match (
metadata.applied_content_set.loader,
metadata.applied_content_set.loader_version.clone(),
) {
(ModLoader::Forge, Some(v)) => {
dependencies.insert(PackDependency::Forge, v)
}
(ModLoader::NeoForge, Some(v)) => {
dependencies.insert(PackDependency::NeoForge, v)
}
(ModLoader::Fabric, Some(v)) => {
dependencies.insert(PackDependency::FabricLoader, v)
}
(ModLoader::Quilt, Some(v)) => {
dependencies.insert(PackDependency::QuiltLoader, v)
}
(ModLoader::Vanilla, _) => None,
_ => {
return Err(crate::ErrorKind::OtherError(
"Loader version mismatch".to_string(),
)
.into());
}
};
dependencies.insert(
PackDependency::Minecraft,
metadata.applied_content_set.game_version.clone(),
);
let state = State::get().await?;
let projects = get_projects(
&metadata.instance.id,
Some(CacheBehaviour::MustRevalidate),
)
.await?
.into_iter()
.filter_map(|(path, file)| match file.metadata {
Some(metadata) => Some((path, metadata.version_id)),
_ => None,
})
.collect::<Vec<_>>();
let versions = CachedEntry::get_version_many(
&projects.iter().map(|x| &*x.1).collect::<Vec<_>>(),
None,
&state.pool,
&state.api_semaphore,
)
.await?;
let files = projects
.into_iter()
.filter_map(|(path, version_id)| {
if let Some(version) = versions.iter().find(|x| x.id == version_id)
{
let mut env = HashMap::new();
env.insert(EnvType::Client, SideType::Required);
env.insert(EnvType::Server, SideType::Required);
let Some(primary_file) = version.files.first() else {
return Some(Err(crate::ErrorKind::OtherError(format!(
"No primary file found for mod at: {path}"
))
.as_error()));
};
let file_size = primary_file.size;
let downloads = vec![primary_file.url.clone()];
let hashes = primary_file
.hashes
.clone()
.into_iter()
.map(|(h1, h2)| (PackFileHash::from(h1), h2))
.collect();
Some(Ok(PackFile {
path: match path.try_into() {
Ok(path) => path,
Err(_) => {
return Some(Err(crate::ErrorKind::OtherError(
"Invalid file path in project".into(),
)
.as_error()));
}
},
hashes,
env: Some(env),
downloads,
file_size,
}))
} else {
None
}
})
.collect::<crate::Result<Vec<PackFile>>>()?;
Ok(PackFormat {
game: "minecraft".to_string(),
format_version: 1,
version_id,
name: metadata.instance.name.clone(),
summary: description,
files,
dependencies,
})
}
#[async_recursion::async_recursion]
async fn add_all_recursive_folder_paths(
folder: &PathBuf,
output: &mut Vec<PathBuf>,
) -> crate::Result<()> {
let mut read_dir = io::read_dir(folder).await?;
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|e| IOError::with_path(e, folder))?
{
let path = entry.path();
if path.is_dir() {
add_all_recursive_folder_paths(&path, output).await?;
} else {
output.push(path);
}
}
Ok(())
}