Compare commits

...

7 Commits

Author SHA1 Message Date
Wyatt Verchere
6e147cc9db fmt clippy prettier 2024-01-30 19:47:44 -08:00
Wyatt Verchere
d2834ce720 Merge branch 'shared-profiles-backend' of http://github.com/modrinth/theseus into shared-profiles-backend 2024-01-30 19:44:12 -08:00
Wyatt Verchere
f9beea8ef2 fmt clippy prettier 2024-01-30 19:39:46 -08:00
thesuzerain
1018d05e36 working acceptance + download 2024-01-30 19:14:47 -08:00
Wyatt Verchere
719aded698 removing users 2024-01-30 19:13:22 -08:00
Wyatt Verchere
6b99f82cea api modifications 2024-01-30 17:42:58 -08:00
Wyatt Verchere
bdde054036 first draft 2024-01-30 17:35:13 -08:00
40 changed files with 1638 additions and 133 deletions

20
Cargo.lock generated
View File

@ -2498,6 +2498,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "minidump-common" name = "minidump-common"
version = "0.14.0" version = "0.14.0"
@ -3582,6 +3592,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"mime_guess",
"native-tls", "native-tls",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
@ -5237,6 +5248,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "unicase"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
dependencies = [
"version_check",
]
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.13" version = "0.3.13"

View File

@ -46,7 +46,7 @@ indicatif = { version = "0.17.3", optional = true }
async-tungstenite = { version = "0.22.1", features = ["tokio-runtime", "tokio-native-tls"] } async-tungstenite = { version = "0.22.1", features = ["tokio-runtime", "tokio-native-tls"] }
futures = "0.3" futures = "0.3"
reqwest = { version = "0.11", features = ["json", "stream"] } reqwest = { version = "0.11", features = ["json", "stream", "multipart", "blocking"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-stream = { version = "0.1", features = ["fs"] } tokio-stream = { version = "0.1", features = ["fs"] }
async-recursion = "1.0.4" async-recursion = "1.0.4"

View File

@ -14,6 +14,9 @@ use crate::{
/// (Does not include modrinth://) /// (Does not include modrinth://)
pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> { pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
Ok(match sublink.split_once('/') { Ok(match sublink.split_once('/') {
Some(("shared_profile", link)) => CommandPayload::OpenSharedProfile {
link: link.to_string(),
},
// /mod/{id} - Installs a mod of mod id // /mod/{id} - Installs a mod of mod id
Some(("mod", id)) => CommandPayload::InstallMod { id: id.to_string() }, Some(("mod", id)) => CommandPayload::InstallMod { id: id.to_string() },
// /version/{id} - Installs a specific version of id // /version/{id} - Installs a specific version of id

View File

@ -11,6 +11,7 @@ pub mod process;
pub mod profile; pub mod profile;
pub mod safety; pub mod safety;
pub mod settings; pub mod settings;
pub mod shared_profile;
pub mod tags; pub mod tags;
pub mod data { pub mod data {
@ -29,7 +30,7 @@ pub mod prelude {
event::CommandPayload, event::CommandPayload,
jre, metadata, pack, process, jre, metadata, pack, process,
profile::{self, create, Profile}, profile::{self, create, Profile},
settings, settings, shared_profile,
state::JavaGlobals, state::JavaGlobals,
state::{Dependency, ProfilePathId, ProjectPathId}, state::{Dependency, ProfilePathId, ProjectPathId},
util::{ util::{

View File

@ -215,7 +215,7 @@ async fn import_atlauncher_unmanaged(
.clone() .clone()
.unwrap_or_else(|| backup_name.to_string()); .unwrap_or_else(|| backup_name.to_string());
prof.install_stage = ProfileInstallStage::PackInstalling; prof.install_stage = ProfileInstallStage::PackInstalling;
prof.metadata.linked_data = Some(LinkedData { prof.metadata.linked_data = Some(LinkedData::ModrinthModpack {
project_id: description.project_id.clone(), project_id: description.project_id.clone(),
version_id: description.version_id.clone(), version_id: description.version_id.clone(),
locked: Some( locked: Some(

View File

@ -159,7 +159,7 @@ pub fn get_profile_from_pack(
} => CreatePackProfile { } => CreatePackProfile {
name: title, name: title,
icon_url, icon_url,
linked_data: Some(LinkedData { linked_data: Some(LinkedData::ModrinthModpack {
project_id: Some(project_id), project_id: Some(project_id),
version_id: Some(version_id), version_id: Some(version_id),
locked: Some(true), locked: Some(true),
@ -391,16 +391,25 @@ pub async fn set_profile_information(
let project_id = description.project_id.clone(); let project_id = description.project_id.clone();
let version_id = description.version_id.clone(); let version_id = description.version_id.clone();
prof.metadata.linked_data = if project_id.is_some() prof.metadata.linked_data =
&& version_id.is_some() if project_id.is_some() && version_id.is_some() {
{ Some(LinkedData::ModrinthModpack {
Some(LinkedData {
project_id, project_id,
version_id, version_id,
locked: if !ignore_lock { locked: if !ignore_lock {
Some(true) Some(true)
} else { } else {
prof.metadata.linked_data.as_ref().and_then(|x| x.locked) prof.metadata.linked_data.as_ref().and_then(|x| {
if let LinkedData::ModrinthModpack {
locked: Some(locked),
..
} = x
{
Some(*locked)
} else {
None
}
})
}, },
}) })
} else { } else {

View File

@ -102,11 +102,14 @@ pub async fn profile_create(
} }
profile.metadata.linked_data = linked_data; profile.metadata.linked_data = linked_data;
if let Some(linked_data) = &mut profile.metadata.linked_data { if let Some(LinkedData::ModrinthModpack {
linked_data.locked = Some( project_id,
linked_data.project_id.is_some() version_id,
&& linked_data.version_id.is_some(), locked,
); ..
}) = &mut profile.metadata.linked_data
{
*locked = Some(project_id.is_some() && version_id.is_some());
} }
emit_profile( emit_profile(

View File

@ -8,6 +8,7 @@ use crate::pack::install_from::{
EnvType, PackDependency, PackFile, PackFileHash, PackFormat, EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
}; };
use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId}; use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId};
use crate::state::LinkedData;
use crate::state::{InnerProjectPathUnix, ProjectMetadata, SideType}; use crate::state::{InnerProjectPathUnix, ProjectMetadata, SideType};
use crate::util::fetch; use crate::util::fetch;
@ -67,13 +68,11 @@ pub async fn get(
let state = State::get().await?; let state = State::get().await?;
let profiles = state.profiles.read().await; let profiles = state.profiles.read().await;
let mut profile = profiles.0.get(path).cloned(); let mut profile = profiles.0.get(path).cloned();
if clear_projects.unwrap_or(false) { if clear_projects.unwrap_or(false) {
if let Some(profile) = &mut profile { if let Some(profile) = &mut profile {
profile.projects = HashMap::new(); profile.projects = HashMap::new();
} }
} }
Ok(profile) Ok(profile)
} }
@ -117,15 +116,14 @@ pub async fn get_mod_full_path(
project_path: &ProjectPathId, project_path: &ProjectPathId,
) -> crate::Result<PathBuf> { ) -> crate::Result<PathBuf> {
if get(profile_path, Some(true)).await?.is_some() { if get(profile_path, Some(true)).await?.is_some() {
let full_path = io::canonicalize( let full_path =
project_path.get_full_path(profile_path.clone()).await?, io::canonicalize(project_path.get_full_path(profile_path).await?)?;
)?;
return Ok(full_path); return Ok(full_path);
} }
Err(crate::ErrorKind::OtherError(format!( Err(crate::ErrorKind::OtherError(format!(
"Tried to get the full path of a nonexistent or unloaded project at path {}!", "Tried to get the full path of a nonexistent or unloaded project at path {}!",
project_path.get_full_path(profile_path.clone()).await?.display() project_path.get_full_path(profile_path).await?.display()
)) ))
.into()) .into())
} }
@ -874,7 +872,13 @@ pub async fn try_update_playtime(path: &ProfilePathId) -> crate::Result<()> {
let res = if updated_recent_playtime > 0 { let res = if updated_recent_playtime > 0 {
// Create update struct to send to Labrinth // Create update struct to send to Labrinth
let modrinth_pack_version_id = let modrinth_pack_version_id =
profile.metadata.linked_data.and_then(|l| l.version_id); profile.metadata.linked_data.and_then(|l| {
if let LinkedData::ModrinthModpack { version_id, .. } = l {
Some(version_id)
} else {
None
}
});
let playtime_update_json = json!({ let playtime_update_json = json!({
"seconds": updated_recent_playtime, "seconds": updated_recent_playtime,
"loader": profile.metadata.loader.to_string(), "loader": profile.metadata.loader.to_string(),
@ -892,7 +896,7 @@ pub async fn try_update_playtime(path: &ProfilePathId) -> crate::Result<()> {
let creds = state.credentials.read().await; let creds = state.credentials.read().await;
fetch::post_json( fetch::post_json(
"https://api.modrinth.com/analytics/playtime", "https://staging-api.modrinth.com/analytics/playtime",
serde_json::to_value(hashmap)?, serde_json::to_value(hashmap)?,
&state.fetch_semaphore, &state.fetch_semaphore,
&creds, &creds,

View File

@ -6,7 +6,7 @@ use crate::{
pack::{self, install_from::generate_pack_from_version_id}, pack::{self, install_from::generate_pack_from_version_id},
prelude::{ProfilePathId, ProjectPathId}, prelude::{ProfilePathId, ProjectPathId},
profile::get, profile::get,
state::{ProfileInstallStage, Project}, state::{LinkedData, ProfileInstallStage, Project},
LoadingBarType, State, LoadingBarType, State,
}; };
use futures::try_join; use futures::try_join;
@ -30,15 +30,14 @@ pub async fn update_managed_modrinth_version(
}; };
// Extract modrinth pack information, if appropriate // Extract modrinth pack information, if appropriate
let linked_data = profile let Some(LinkedData::ModrinthModpack {
.metadata project_id: Some(ref project_id),
.linked_data version_id: Some(ref version_id),
.as_ref() ..
.ok_or_else(unmanaged_err)?; }) = profile.metadata.linked_data
let project_id: &String = else {
linked_data.project_id.as_ref().ok_or_else(unmanaged_err)?; return Err(unmanaged_err().into());
let version_id = };
linked_data.version_id.as_ref().ok_or_else(unmanaged_err)?;
// Replace the pack with the new version // Replace the pack with the new version
replace_managed_modrinth( replace_managed_modrinth(
@ -107,15 +106,14 @@ pub async fn repair_managed_modrinth(
.await?; .await?;
// Extract modrinth pack information, if appropriate // Extract modrinth pack information, if appropriate
let linked_data = profile let Some(LinkedData::ModrinthModpack {
.metadata project_id: Some(ref project_id),
.linked_data version_id: Some(ref version_id),
.as_ref() ..
.ok_or_else(unmanaged_err)?; }) = profile.metadata.linked_data
let project_id: &String = else {
linked_data.project_id.as_ref().ok_or_else(unmanaged_err)?; return Err(unmanaged_err().into());
let version_id = };
linked_data.version_id.as_ref().ok_or_else(unmanaged_err)?;
// Replace the pack with the same version // Replace the pack with the same version
replace_managed_modrinth( replace_managed_modrinth(

View File

@ -1,6 +1,6 @@
//! Theseus profile management interface //! Theseus profile management interface
use std::path::{PathBuf, Path}; use std::path::{Path, PathBuf};
use tokio::fs; use tokio::fs;
use io::IOError; use io::IOError;
@ -10,7 +10,7 @@ use crate::{
event::emit::{emit_loading, init_loading}, event::emit::{emit_loading, init_loading},
prelude::DirectoryInfo, prelude::DirectoryInfo,
state::{self, Profiles}, state::{self, Profiles},
util::{io, fetch}, util::{fetch, io},
}; };
pub use crate::{ pub use crate::{
state::{ state::{
@ -109,7 +109,6 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
let old_config_dir = let old_config_dir =
state_write.directories.config_dir.read().await.clone(); state_write.directories.config_dir.read().await.clone();
// Reset file watcher // Reset file watcher
tracing::trace!("Reset file watcher"); tracing::trace!("Reset file watcher");
let file_watcher = state::init_watcher().await?; let file_watcher = state::init_watcher().await?;
@ -125,13 +124,17 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
.await .await
.map_err(|e| IOError::with_path(e, &old_config_dir))? .map_err(|e| IOError::with_path(e, &old_config_dir))?
{ {
let entry_path = entry.path(); let entry_path = entry.path();
if let Some(file_name) = entry_path.file_name() { if let Some(file_name) = entry_path.file_name() {
// We are only moving the profiles and metadata folders // We are only moving the profiles and metadata folders
if file_name == state::PROFILES_FOLDER_NAME || file_name == state::METADATA_FOLDER_NAME { if file_name == state::PROFILES_FOLDER_NAME
|| file_name == state::METADATA_FOLDER_NAME
{
if across_drives { if across_drives {
entries.extend(crate::pack::import::get_all_subfiles(&entry_path).await?); entries.extend(
crate::pack::import::get_all_subfiles(&entry_path)
.await?,
);
deletable_entries.push(entry_path.clone()); deletable_entries.push(entry_path.clone());
} else { } else {
entries.push(entry_path.clone()); entries.push(entry_path.clone());
@ -151,8 +154,7 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
} else { } else {
io::rename(entry_path.clone(), new_path.clone()).await?; io::rename(entry_path.clone(), new_path.clone()).await?;
} }
emit_loading(&loading_bar, 80.0 * (1.0 / num_entries), None) emit_loading(&loading_bar, 80.0 * (1.0 / num_entries), None).await?;
.await?;
} }
tracing::trace!("Setting configuration setting"); tracing::trace!("Setting configuration setting");
@ -199,7 +201,8 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
&loading_bar, &loading_bar,
10.0 * (1.0 / deletable_entries_len as f64), 10.0 * (1.0 / deletable_entries_len as f64),
None, None,
).await?; )
.await?;
} }
// Reset file watcher // Reset file watcher
@ -228,7 +231,6 @@ fn is_different_drive(path1: &Path, path2: &Path) -> bool {
root1 != root2 root1 != root2
} }
pub async fn is_dir_writeable(new_config_dir: PathBuf) -> crate::Result<bool> { pub async fn is_dir_writeable(new_config_dir: PathBuf) -> crate::Result<bool> {
let temp_path = new_config_dir.join(".tmp"); let temp_path = new_config_dir.join(".tmp");
match fs::write(temp_path.clone(), "test").await { match fs::write(temp_path.clone(), "test").await {

View File

@ -0,0 +1,889 @@
use std::path::PathBuf;
use crate::{
config::MODRINTH_API_URL_INTERNAL,
prelude::{
LinkedData, ModLoader, ProfilePathId, ProjectMetadata, ProjectPathId,
},
profile,
state::{Profile, Profiles},
util::{
fetch::{fetch_advanced, REQWEST_CLIENT},
io,
},
};
use chrono::{DateTime, Utc};
use reqwest::Method;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug)]
pub struct SharedProfile {
pub id: String,
pub name: String,
pub is_owned: bool, // Whether we are the owner (intentionally redundant)
pub owner_id: String,
pub icon_url: Option<String>,
pub loader: ModLoader,
pub loader_version: String,
pub game_version: String,
pub updated_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub versions: Vec<String>,
pub overrides: Vec<SharedProfileOverride>,
pub share_links: Option<Vec<SharedProfileLink>>,
pub users: Option<Vec<String>>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct SharedProfileLink {
pub id: String,
pub created: DateTime<Utc>,
pub expires: DateTime<Utc>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct SharedProfileOverride {
pub url: String,
pub install_path: PathBuf,
pub hashes: SharedProfileOverrideHashes,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct SharedProfileOverrideHashes {
pub sha1: String,
pub sha512: String,
}
// Simplified version of SharedProfile- this is what is returned from the Labrinth API
// This is not used, except for requests where we are not a member of the shared profile
// (ie: previewing a shared profile from a link, before accepting it)
#[derive(Deserialize, Serialize, Debug)]
pub struct SharedProfileResponse {
pub id: String,
pub name: String,
pub owner_id: String,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
pub icon_url: Option<String>,
pub loader: ModLoader,
pub game: String,
pub loader_version: String,
pub game_version: String,
// Present only if we are the owner
pub share_links: Option<Vec<SharedProfileLink>>,
pub users: Option<Vec<String>>,
}
// Create a new shared profile from ProfilePathId
// This converts the LinkedData to a SharedProfile and uploads it to the Labrinth API
#[tracing::instrument]
pub async fn create(profile_id: ProfilePathId) -> crate::Result<()> {
let state = crate::State::get().await?;
let profile: Profile =
profile::get(&profile_id, None).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(profile_id.to_string())
})?;
let creds = state.credentials.read().await;
let creds = creds
.0
.as_ref()
.ok_or_else(|| crate::ErrorKind::NoCredentialsError)?;
// Currently existing linked data should fail
match profile.metadata.linked_data {
Some(LinkedData::SharedProfile { .. }) => {
return Err(crate::ErrorKind::OtherError(
"Profile already linked to a shared profile".to_string(),
)
.as_error());
}
Some(LinkedData::ModrinthModpack { .. }) => {
return Err(crate::ErrorKind::OtherError(
"Profile already linked to a modrinth project".to_string(),
)
.as_error());
}
None => {}
};
let name = profile.metadata.name;
let loader = profile.metadata.loader;
let loader_version = profile.metadata.loader_version;
let game_version = profile.metadata.game_version;
let modrinth_projects: Vec<_> = profile
.projects
.iter()
.filter_map(|(_, project)| {
if let ProjectMetadata::Modrinth { ref version, .. } =
project.metadata
{
Some(&version.id)
} else {
None
}
})
.collect();
let override_files: Vec<_> = profile
.projects
.iter()
.filter_map(|(id, project)| {
if let ProjectMetadata::Inferred { .. } = project.metadata {
Some(id)
} else {
None
}
})
.collect();
// Create the profile on the Labrinth API
let response = REQWEST_CLIENT
.post(format!("{MODRINTH_API_URL_INTERNAL}client/profile"))
.header("Authorization", &creds.session)
.json(&serde_json::json!({
"name": name,
"loader": loader.as_api_str(),
"loader_version": loader_version.map(|x| x.id).unwrap_or_default(),
"game": "minecraft-java",
"game_version": game_version,
"versions": modrinth_projects,
}))
.send()
.await?;
let profile_response = response.json::<serde_json::Value>().await?;
// Extract the profile ID from the response
let shared_profile_id = profile_response["id"]
.as_str()
.ok_or_else(|| {
crate::ErrorKind::OtherError(
"Could not parse response from Labrinth API".to_string(),
)
})?
.to_string();
// Unmanaged projects
let mut data = vec![]; // 'data' field, giving installation context to labrinth
let mut parts = vec![]; // 'parts' field, containing the actual files
for override_file in override_files {
let path = override_file.get_inner_path_unix();
let Some(name) = path.0.split('/').last().map(|x| x.to_string()) else {
continue;
};
// Load override to file
let full_path = &override_file.get_full_path(&profile_id).await?;
let file_bytes = io::read(full_path).await?;
let ext = full_path
.extension()
.and_then(|x| x.to_str())
.unwrap_or_default();
let mime = project_file_type(ext).ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Could not determine file type for {}",
ext
))
})?;
data.push(serde_json::json!({
"file_name": name.clone(),
"install_path": path
}));
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name(name.clone())
.mime_str(mime)?;
parts.push((name.clone(), part));
}
// Build multipart with 'data' field first
let mut multipart = reqwest::multipart::Form::new().percent_encode_noop();
let json_part =
reqwest::multipart::Part::text(serde_json::to_string(&data)?); //mime_str("application/json")?;
multipart = multipart.part("data", json_part);
for (name, part) in parts {
multipart = multipart.part(name, part);
}
let response = REQWEST_CLIENT.post(
format!("{MODRINTH_API_URL_INTERNAL}client/profile/{shared_profile_id}/override"),
)
.header("Authorization", &creds.session)
.multipart(multipart);
response.send().await?.error_for_status()?;
// Update the profile with the new linked data
profile::edit(&profile_id, |profile| {
let shared_profile_id = shared_profile_id.clone();
profile.metadata.linked_data = Some(LinkedData::SharedProfile {
profile_id: shared_profile_id,
is_owner: true,
});
async { Ok(()) }
})
.await?;
// Sync
crate::State::sync().await?;
Ok(())
}
pub fn project_file_type(ext: &str) -> Option<&str> {
match ext {
"jar" => Some("application/java-archive"),
"zip" | "litemod" => Some("application/zip"),
"mrpack" => Some("application/x-modrinth-modpack+zip"),
_ => None,
}
}
#[tracing::instrument]
pub async fn get_all() -> crate::Result<Vec<SharedProfile>> {
let state = crate::State::get().await?;
let creds = state.credentials.read().await;
let creds = creds
.0
.as_ref()
.ok_or_else(|| crate::ErrorKind::NoCredentialsError)?;
let response = REQWEST_CLIENT
.get(format!("{MODRINTH_API_URL_INTERNAL}client/user"))
.header("Authorization", &creds.session)
.send()
.await?
.error_for_status()?;
// First, get list of shared profiles the user has access to
let profiles = response.json::<Vec<SharedProfileResponse>>().await?;
// Next, get files for each shared profile
// TODO: concurrent requests
#[derive(Serialize, Deserialize)]
pub struct SharedFiles {
pub version_ids: Vec<String>,
pub override_cdns: Vec<SharedProfileOverride>,
}
let mut shared_profiles = vec![];
for profile in profiles.into_iter() {
if profile.game != "minecraft-java" {
continue;
}
let id = profile.id;
let response = REQWEST_CLIENT
.get(format!(
"{MODRINTH_API_URL_INTERNAL}client/profile/{id}/files"
))
.header("Authorization", &creds.session)
.send()
.await?
.error_for_status()?;
let files = response.json::<SharedFiles>().await?;
shared_profiles.push(SharedProfile {
id,
name: profile.name,
is_owned: profile.owner_id
== state
.credentials
.read()
.await
.0
.as_ref()
.map(|x| x.user.id.as_str())
.unwrap_or_default(),
owner_id: profile.owner_id,
loader: profile.loader,
loader_version: profile.loader_version,
game_version: profile.game_version,
icon_url: profile.icon_url,
versions: files.version_ids,
overrides: files.override_cdns,
share_links: profile.share_links,
users: profile.users,
updated_at: profile.updated,
created_at: profile.created,
});
}
Ok(shared_profiles)
}
#[tracing::instrument]
pub async fn install(
shared_profile_id: String,
) -> crate::Result<ProfilePathId> {
let state = crate::State::get().await?;
let shared_profile = get_all()
.await?
.into_iter()
.find(|x| x.id == shared_profile_id)
.ok_or_else(|| {
crate::ErrorKind::OtherError("Profile not found".to_string())
})?;
let linked_data = LinkedData::SharedProfile {
profile_id: shared_profile.id,
is_owner: shared_profile.is_owned,
};
// Create new profile
let profile_id = crate::profile::create::profile_create(
shared_profile.name,
shared_profile.game_version,
shared_profile.loader,
Some(shared_profile.loader_version),
None,
shared_profile.icon_url,
Some(linked_data),
None,
None,
)
.await?;
// Get the profile
let profile: Profile =
profile::get(&profile_id, None).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(profile_id.to_string())
})?;
let creds = state.credentials.read().await;
// TODO: concurrent requests
// Add projects
for version in shared_profile.versions {
profile.add_project_version(version).await?;
}
for file_override in shared_profile.overrides {
let file = fetch_advanced(
Method::GET,
&file_override.url,
Some(file_override.hashes.sha1.as_str()),
None,
None,
None,
&state.fetch_semaphore,
&creds,
)
.await?;
profile
.add_project_bytes_directly(&file_override.install_path, file)
.await?;
}
Ok(profile_id)
}
// Structure repesenting a synchronization difference between a local profile and a shared profile
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
pub struct SharedModpackFileUpdate {
// Can be false if all other fields are empty
// if the metadata is different
pub is_synced: bool,
// Projects that are in the local profile but not in the shared profile
pub unsynced_projects: Vec<ProjectPathId>,
// Projects that are in the shared profile but not in the local profile
pub missing_versions: Vec<String>,
pub missing_overrides: Vec<SharedProfileOverride>,
}
#[tracing::instrument]
pub async fn check_updated(
profile_id: &ProfilePathId,
shared_profile: &SharedProfile,
) -> crate::Result<SharedModpackFileUpdate> {
let profile: Profile =
profile::get(profile_id, None).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(profile_id.to_string())
})?;
// Check if the metadata is the same- if different, we return false with no file updates
if profile.metadata.name != shared_profile.name
|| profile.metadata.loader != shared_profile.loader
|| profile
.metadata
.loader_version
.map(|x| x.id)
.unwrap_or_default()
!= shared_profile.loader_version
|| profile.metadata.game_version != shared_profile.game_version
{
return Ok(SharedModpackFileUpdate::default());
}
// Check if the projects are the same- we check each override by hash and each modrinth project by version id
let mut modrinth_projects = shared_profile.versions.clone();
let mut overrides = shared_profile.overrides.clone();
let unsynced_projects: Vec<_> = profile
.projects
.into_iter()
.filter_map(|(id, project)| {
match project.metadata {
ProjectMetadata::Modrinth { ref version, .. } => {
if modrinth_projects.contains(&version.id) {
modrinth_projects.retain(|x| x != &version.id);
} else {
return Some(id);
}
}
ProjectMetadata::Inferred { .. } => {
let Some(matching_override) =
overrides.iter().position(|o| {
o.install_path.to_string_lossy()
== id.get_inner_path_unix().0
})
else {
return Some(id);
};
if let Some(o) = overrides.get(matching_override) {
if o.hashes.sha512 != project.sha512 {
return Some(id);
}
} else {
return Some(id);
}
overrides.remove(matching_override);
}
ProjectMetadata::Unknown => {
// TODO: What to do for unknown projects?
return Some(id);
}
}
None
})
.collect();
Ok(SharedModpackFileUpdate {
is_synced: modrinth_projects.is_empty()
&& overrides.is_empty()
&& unsynced_projects.is_empty(),
unsynced_projects,
missing_versions: modrinth_projects,
missing_overrides: overrides,
})
}
// Updates projects for a given ProfilePathId from a SharedProfile
// This updates the local profile to match the shared profile on the Labrinth API
#[tracing::instrument]
pub async fn inbound_sync(profile_id: ProfilePathId) -> crate::Result<()> {
let state = crate::State::get().await?;
let profile: Profile =
profile::get(&profile_id, None).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(profile_id.to_string())
})?;
let creds = state.credentials.read().await;
// Get linked
let shared_profile = match profile.metadata.linked_data {
Some(LinkedData::SharedProfile { ref profile_id, .. }) => profile_id,
_ => {
return Err(crate::ErrorKind::OtherError(
"Profile is not linked to a shared profile".to_string(),
)
.as_error())
}
};
// Get updated shared profile
let shared_profile = get_all()
.await?
.into_iter()
.find(|x| &x.id == shared_profile)
.ok_or_else(|| {
crate::ErrorKind::OtherError(
"Profile is not linked to a shared profile".to_string(),
)
})?;
let update_data = check_updated(&profile_id, &shared_profile).await?;
if update_data.is_synced {
return Ok(());
}
// Remove projects- unsynced projects need to be removed
for project in update_data.unsynced_projects {
profile.remove_project(&project, None).await?;
}
// TODO: concurrent requests
// Add projects- missing projects need to be added
for version in update_data.missing_versions {
profile.add_project_version(version).await?;
}
for file_override in update_data.missing_overrides {
let file = fetch_advanced(
Method::GET,
&file_override.url,
Some(file_override.hashes.sha1.as_str()),
None,
None,
None,
&state.fetch_semaphore,
&creds,
)
.await?;
profile
.add_project_bytes_directly(&file_override.install_path, file)
.await?;
}
Ok(())
}
// Updates metadata for a given ProfilePathId to the Labrinth API
// Must be an owner of the shared profile
#[tracing::instrument]
pub async fn outbound_sync(profile_id: ProfilePathId) -> crate::Result<()> {
let state = crate::State::get().await?;
let profile: Profile =
profile::get(&profile_id, None).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(profile_id.to_string())
})?;
let creds = state.credentials.read().await;
let creds = creds
.0
.as_ref()
.ok_or_else(|| crate::ErrorKind::NoCredentialsError)?;
// Get linked
let shared_profile = match profile.metadata.linked_data {
Some(LinkedData::SharedProfile { profile_id, .. }) => profile_id,
_ => {
return Err(crate::ErrorKind::OtherError(
"Profile is not linked to a shared profile".to_string(),
)
.as_error())
}
};
// Get updated shared profile
let shared_profile = get_all()
.await?
.into_iter()
.find(|x| x.id == shared_profile)
.ok_or_else(|| {
crate::ErrorKind::OtherError(
"Profile is not linked to a shared profile".to_string(),
)
})?;
// Check owner
if !shared_profile.is_owned {
return Err(crate::ErrorKind::OtherError(
"Profile is not owned by the current user".to_string(),
)
.as_error());
}
// Check if we are synced
let update_data = check_updated(&profile_id, &shared_profile).await?;
let id = shared_profile.id;
if update_data.is_synced {
return Ok(());
}
let unsynced = update_data.unsynced_projects;
let projects: Vec<_> = profile
.projects
.clone()
.into_iter()
.filter(|(id, _)| unsynced.contains(id))
.collect();
let unsynced_modrinth_projects: Vec<_> = projects
.iter()
.filter_map(|(_, project)| {
if let ProjectMetadata::Modrinth { ref version, .. } =
project.metadata
{
Some(&version.id)
} else {
None
}
})
.collect();
let unsynced_override_files: Vec<_> = projects
.iter()
.filter_map(|(id, project)| {
if let ProjectMetadata::Inferred { .. } = project.metadata {
Some(id)
} else {
None
}
})
.collect();
// Generate new version set
let mut new_version_set = shared_profile.versions;
for version in update_data.missing_versions {
new_version_set.retain(|x| x != &version);
}
for version in unsynced_modrinth_projects {
new_version_set.push(version.to_string());
}
// Update metadata + versions
REQWEST_CLIENT
.patch(
format!("{MODRINTH_API_URL_INTERNAL}client/profile/{id}"),
)
.header("Authorization", &creds.session)
.json(&serde_json::json!({
"name": profile.metadata.name,
"loader": profile.metadata.loader.as_api_str(),
"loader_version": profile.metadata.loader_version.map(|x| x.id).unwrap_or_default(),
"game": "minecraft-java",
"game_version": profile.metadata.game_version,
"versions": new_version_set,
}))
.send().await?.error_for_status()?;
// Create multipart for uploading new overrides
let mut parts = vec![]; // 'parts' field, containing the actual files
let mut data = vec![]; // 'data' field, giving installation context to labrinth
for override_file in unsynced_override_files {
let path = override_file.get_inner_path_unix();
let Some(name) = path.0.split('/').last().map(|x| x.to_string()) else {
continue;
};
// Load override to file
let full_path = &override_file.get_full_path(&profile_id).await?;
let file_bytes = io::read(full_path).await?;
let ext = full_path
.extension()
.and_then(|x| x.to_str())
.unwrap_or_default();
let mime = project_file_type(ext).ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Could not determine file type for {}",
ext
))
})?;
data.push(serde_json::json!({
"file_name": name.clone(),
"install_path": path
}));
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name(name.clone())
.mime_str(mime)?;
parts.push((name.clone(), part));
}
// Build multipart with 'data' field first
let mut multipart = reqwest::multipart::Form::new().percent_encode_noop();
let json_part =
reqwest::multipart::Part::text(serde_json::to_string(&data)?); //mime_str("application/json")?;
multipart = multipart.part("data", json_part);
for (name, part) in parts {
multipart = multipart.part(name, part);
}
let response = REQWEST_CLIENT
.post(format!(
"{MODRINTH_API_URL_INTERNAL}client/profile/{id}/override"
))
.header("Authorization", &creds.session)
.multipart(multipart);
response.send().await?.error_for_status()?;
// Cannot fail, simply re-checks its synced with the shared profile
Profiles::update_shared_projects().await;
Ok(())
}
pub async fn remove_shared_profile_users(
profile_id: ProfilePathId,
users: Vec<String>,
) -> crate::Result<()> {
let state = crate::State::get().await?;
let profile: Profile =
profile::get(&profile_id, None).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(profile_id.to_string())
})?;
let creds = state.credentials.read().await;
let creds = creds
.0
.as_ref()
.ok_or_else(|| crate::ErrorKind::NoCredentialsError)?;
let shared_profile = match profile.metadata.linked_data {
Some(LinkedData::SharedProfile { profile_id, .. }) => profile_id,
_ => {
return Err(crate::ErrorKind::OtherError(
"Profile is not linked to a shared profile".to_string(),
)
.as_error())
}
};
REQWEST_CLIENT
.patch(format!(
"{MODRINTH_API_URL_INTERNAL}client/profile/{shared_profile}"
))
.header("Authorization", &creds.session)
.json(&serde_json::json!({
"remove_users": users,
}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn remove_shared_profile_links(
profile_id: ProfilePathId,
links: Vec<String>,
) -> crate::Result<()> {
let state = crate::State::get().await?;
let profile: Profile =
profile::get(&profile_id, None).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(profile_id.to_string())
})?;
let creds = state.credentials.read().await;
let creds = creds
.0
.as_ref()
.ok_or_else(|| crate::ErrorKind::NoCredentialsError)?;
let shared_profile = match profile.metadata.linked_data {
Some(LinkedData::SharedProfile { profile_id, .. }) => profile_id,
_ => {
return Err(crate::ErrorKind::OtherError(
"Profile is not linked to a shared profile".to_string(),
)
.as_error())
}
};
REQWEST_CLIENT
.patch(format!(
"{MODRINTH_API_URL_INTERNAL}client/profile/{shared_profile}"
))
.header("Authorization", &creds.session)
.json(&serde_json::json!({
"remove_links": links,
}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn generate_share_link(
profile_id: ProfilePathId,
) -> crate::Result<String> {
let state = crate::State::get().await?;
let profile: Profile =
profile::get(&profile_id, None).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(profile_id.to_string())
})?;
let creds = state.credentials.read().await;
let creds = creds
.0
.as_ref()
.ok_or_else(|| crate::ErrorKind::NoCredentialsError)?;
let shared_profile = match profile.metadata.linked_data {
Some(LinkedData::SharedProfile { profile_id, .. }) => profile_id,
_ => {
return Err(crate::ErrorKind::OtherError(
"Profile is not linked to a shared profile".to_string(),
)
.as_error())
}
};
let response = REQWEST_CLIENT
.post(format!(
"{MODRINTH_API_URL_INTERNAL}client/profile/{shared_profile}/share"
))
.header("Authorization", &creds.session)
.send()
.await?
.error_for_status()?;
let link = response.json::<SharedProfileLink>().await?;
Ok(generate_deep_link(&link))
}
fn generate_deep_link(link: &SharedProfileLink) -> String {
format!("modrinth://shared_profile/{}", link.id)
}
pub async fn accept_share_link(link: String) -> crate::Result<()> {
let state = crate::State::get().await?;
let creds = state.credentials.read().await;
let creds = creds
.0
.as_ref()
.ok_or_else(|| crate::ErrorKind::NoCredentialsError)?;
REQWEST_CLIENT
.post(format!(
"{MODRINTH_API_URL_INTERNAL}client/share/{link}/accept"
))
.header("Authorization", &creds.session)
.send()
.await?
.error_for_status()?;
Ok(())
}
// Gets a shared profile from a share link
// This is done without accepting it- so would not include any link information, and is only usable for basic info
pub async fn get_from_link(
link: String,
) -> crate::Result<SharedProfileResponse> {
let state = crate::State::get().await?;
let creds = state.credentials.read().await;
let creds = creds
.0
.as_ref()
.ok_or_else(|| crate::ErrorKind::NoCredentialsError)?;
let response = REQWEST_CLIENT
.get(format!("{MODRINTH_API_URL_INTERNAL}client/share/{link}"))
.header("Authorization", &creds.session)
.send()
.await?
.error_for_status()?;
let profile = response.json::<SharedProfileResponse>().await?;
Ok(profile)
}

View File

@ -1,3 +1,5 @@
//! Configuration structs //! Configuration structs
pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/"; pub const MODRINTH_API_URL: &str = "https://staging-api.modrinth.com/v2/";
pub const MODRINTH_API_URL_INTERNAL: &str =
"https://staging-api.modrinth.com/_internal/";

View File

@ -229,6 +229,9 @@ pub enum CommandPayload {
// run or install .mrpack // run or install .mrpack
path: PathBuf, path: PathBuf,
}, },
OpenSharedProfile {
link: String,
},
} }
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]

View File

@ -247,13 +247,16 @@ impl State {
tokio::task::spawn(async { tokio::task::spawn(async {
if let Ok(state) = crate::State::get().await { if let Ok(state) = crate::State::get().await {
if !*state.offline.read().await { if !*state.offline.read().await {
// Resolve update_creds first, as it might affect calls made by other updates
let _ = CredentialsStore::update_creds().await;
let res1 = Profiles::update_modrinth_versions(); let res1 = Profiles::update_modrinth_versions();
let res2 = Tags::update(); let res2 = Tags::update();
let res3 = Metadata::update(); let res3 = Metadata::update();
let res4 = Profiles::update_projects(); let res4 = Profiles::update_projects();
let res5 = Settings::update_java(); let res5 = Settings::update_java();
let res6 = CredentialsStore::update_creds(); let res6 = Settings::update_default_user();
let res7 = Settings::update_default_user(); let res7 = Profiles::update_shared_projects();
let _ = join!(res1, res2, res3, res4, res5, res6, res7); let _ = join!(res1, res2, res3, res4, res5, res6, res7);
} }

View File

@ -116,7 +116,7 @@ pub struct ModrinthAuthFlow {
impl ModrinthAuthFlow { impl ModrinthAuthFlow {
pub async fn new(provider: &str) -> crate::Result<Self> { pub async fn new(provider: &str) -> crate::Result<Self> {
let (socket, _) = async_tungstenite::tokio::connect_async(format!( let (socket, _) = async_tungstenite::tokio::connect_async(format!(
"wss://api.modrinth.com/v2/auth/ws?provider={provider}" "wss://staging-api.modrinth.com/v2/auth/ws?provider={provider}"
)) ))
.await?; .await?;
Ok(Self { socket }) Ok(Self { socket })
@ -209,7 +209,7 @@ async fn get_result_from_res(
} }
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
struct Session { struct Session {
session: String, session: String,
} }
@ -351,6 +351,7 @@ pub async fn refresh_credentials(
} }
} }
credentials_store.save().await?;
Ok(()) Ok(())
} }

View File

@ -4,13 +4,14 @@ use crate::data::DirectoryInfo;
use crate::event::emit::{emit_profile, emit_warning}; use crate::event::emit::{emit_profile, emit_warning};
use crate::event::ProfilePayloadType; use crate::event::ProfilePayloadType;
use crate::prelude::JavaVersion; use crate::prelude::JavaVersion;
use crate::shared_profile::SharedModpackFileUpdate;
use crate::state::projects::Project; use crate::state::projects::Project;
use crate::state::{ModrinthVersion, ProjectMetadata, ProjectType}; use crate::state::{ModrinthVersion, ProjectMetadata, ProjectType};
use crate::util::fetch::{ use crate::util::fetch::{
fetch, fetch_json, write, write_cached_icon, IoSemaphore, fetch, fetch_json, write, write_cached_icon, IoSemaphore,
}; };
use crate::util::io::{self, IOError}; use crate::util::io::{self, IOError};
use crate::State; use crate::{shared_profile, State};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use daedalus::get_hash; use daedalus::get_hash;
use daedalus::modded::LoaderVersion; use daedalus::modded::LoaderVersion;
@ -164,7 +165,7 @@ impl ProjectPathId {
pub async fn get_full_path( pub async fn get_full_path(
&self, &self,
profile: ProfilePathId, profile: &ProfilePathId,
) -> crate::Result<PathBuf> { ) -> crate::Result<PathBuf> {
let profile_dir = profile.get_full_path().await?; let profile_dir = profile.get_full_path().await?;
Ok(profile_dir.join(&self.0)) Ok(profile_dir.join(&self.0))
@ -209,7 +210,14 @@ pub struct Profile {
pub hooks: Option<Hooks>, pub hooks: Option<Hooks>,
pub projects: HashMap<ProjectPathId, Project>, pub projects: HashMap<ProjectPathId, Project>,
#[serde(default)] #[serde(default)]
pub modrinth_update_version: Option<String>, pub sync_update_version: Option<ProfileUpdateData>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(untagged)]
pub enum ProfileUpdateData {
ModrinthModpack(String),
SharedProfile(SharedModpackFileUpdate),
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
@ -244,12 +252,19 @@ pub struct ProfileMetadata {
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LinkedData { #[serde(rename_all = "snake_case")]
pub project_id: Option<String>, pub enum LinkedData {
pub version_id: Option<String>, ModrinthModpack {
project_id: Option<String>,
version_id: Option<String>,
#[serde(default = "default_locked")] #[serde(default = "default_locked")]
pub locked: Option<bool>, locked: Option<bool>,
},
SharedProfile {
profile_id: String,
is_owner: bool,
},
} }
// Called if linked_data is present but locked is not // Called if linked_data is present but locked is not
@ -344,7 +359,7 @@ impl Profile {
resolution: None, resolution: None,
fullscreen: None, fullscreen: None,
hooks: None, hooks: None,
modrinth_update_version: None, sync_update_version: None,
}) })
} }
@ -424,6 +439,10 @@ impl Profile {
&creds, &creds,
) )
.await?; .await?;
// Check existing shared profiles for updates
tokio::task::spawn(Profiles::update_shared_projects());
drop(creds); drop(creds);
let mut new_profiles = state.profiles.write().await; let mut new_profiles = state.profiles.write().await;
@ -580,6 +599,49 @@ impl Profile {
Ok((path, version)) Ok((path, version))
} }
#[tracing::instrument(skip(self))]
#[theseus_macros::debug_pin]
pub async fn add_project_bytes_directly(
&self,
relative_path: &Path,
bytes: bytes::Bytes,
) -> crate::Result<ProjectPathId> {
let state = State::get().await?;
let file_path = self.get_profile_full_path().await?.join(relative_path);
let project_path_id = ProjectPathId::new(relative_path);
write(&file_path, &bytes, &state.io_semaphore).await?;
let file_name = relative_path
.file_name()
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Could not find file name for {:?}",
relative_path
))
})?
.to_string_lossy();
let hash = get_hash(bytes).await?;
{
let mut profiles = state.profiles.write().await;
if let Some(profile) = profiles.0.get_mut(&self.profile_id()) {
profile.projects.insert(
project_path_id.clone(),
Project {
sha512: hash,
disabled: false,
metadata: ProjectMetadata::Unknown,
file_name: file_name.to_string(),
},
);
profile.metadata.date_modified = Utc::now();
}
}
Ok(project_path_id)
}
#[tracing::instrument(skip(self, bytes))] #[tracing::instrument(skip(self, bytes))]
#[theseus_macros::debug_pin] #[theseus_macros::debug_pin]
pub async fn add_project_bytes( pub async fn add_project_bytes(
@ -872,14 +934,19 @@ impl Profiles {
pub async fn update_modrinth_versions() { pub async fn update_modrinth_versions() {
let res = async { let res = async {
let state = State::get().await?; let state = State::get().await?;
// First, we'll fetch updates for all Modrinth modpacks
// Temporarily store all profiles that have modrinth linked data // Temporarily store all profiles that have modrinth linked data
let mut modrinth_updatables: Vec<(ProfilePathId, String)> = let mut modrinth_updatables: Vec<(ProfilePathId, String)> =
Vec::new(); Vec::new();
{ {
let profiles = state.profiles.read().await; let profiles = state.profiles.read().await;
for (profile_path, profile) in profiles.0.iter() { for (profile_path, profile) in profiles.0.iter() {
if let Some(linked_data) = &profile.metadata.linked_data { if let Some(LinkedData::ModrinthModpack {
if let Some(linked_project) = &linked_data.project_id { project_id: Some(ref linked_project),
..
}) = &profile.metadata.linked_data
{
modrinth_updatables.push(( modrinth_updatables.push((
profile_path.clone(), profile_path.clone(),
linked_project.clone(), linked_project.clone(),
@ -887,7 +954,6 @@ impl Profiles {
} }
} }
} }
}
// Fetch online from Modrinth each latest version // Fetch online from Modrinth each latest version
future::try_join_all(modrinth_updatables.into_iter().map( future::try_join_all(modrinth_updatables.into_iter().map(
@ -922,10 +988,12 @@ impl Profiles {
.contains(&loader.as_api_str().to_string()) .contains(&loader.as_api_str().to_string())
}); });
if let Some(recent_version) = recent_version { if let Some(recent_version) = recent_version {
profile.modrinth_update_version = profile.sync_update_version =
Some(recent_version.id.clone()); Some(ProfileUpdateData::ModrinthModpack(
recent_version.id.clone(),
));
} else { } else {
profile.modrinth_update_version = None; profile.sync_update_version = None;
} }
} }
drop(new_profiles); drop(new_profiles);
@ -953,6 +1021,76 @@ impl Profiles {
}; };
} }
#[tracing::instrument]
#[theseus_macros::debug_pin]
pub async fn update_shared_projects() {
let res = async {
let state = State::get().await?;
// Next, get updates for all shared profiles
// Get SharedProfiles for all available
let shared_profiles = shared_profile::get_all().await?;
let mut update_profiles = HashMap::new();
{
let profiles = state.profiles.read().await;
for (profile_path, profile) in profiles.0.iter() {
if let Some(LinkedData::SharedProfile {
profile_id, ..
}) = &profile.metadata.linked_data
{
if let Some(shared_profile) =
shared_profiles.iter().find(|x| x.id == *profile_id)
{
// Check for update
let update = shared_profile::check_updated(
profile_path,
shared_profile,
)
.await?;
update_profiles
.insert(profile_path.clone(), update);
}
}
}
}
{
let mut new_profiles = state.profiles.write().await;
for (profile_path, update) in update_profiles.iter() {
if let Some(profile) = new_profiles.0.get_mut(profile_path)
{
profile.sync_update_version = Some(
ProfileUpdateData::SharedProfile(update.clone()),
);
}
}
}
{
let profiles = state.profiles.read().await;
profiles.sync().await?;
for (profile_path, _) in update_profiles.iter() {
let Some(profile) = profiles.0.get(profile_path) else {
continue;
};
emit_profile(
profile.uuid,
profile_path,
&profile.metadata.name,
ProfilePayloadType::Edited,
)
.await?;
}
}
Ok::<(), crate::Error>(())
}
.await;
match res {
Ok(()) => {}
Err(err) => {
tracing::warn!("Unable to update modrinth versions: {err}")
}
};
}
#[tracing::instrument(skip(self, profile))] #[tracing::instrument(skip(self, profile))]
#[theseus_macros::debug_pin] #[theseus_macros::debug_pin]
pub async fn insert( pub async fn insert(

View File

@ -1,10 +1,10 @@
// IO error // IO error
// A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error. // A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error.
use std::{path::Path, io::Write}; use std::{io::Write, path::Path};
use tempfile::NamedTempFile;
use tauri::async_runtime::spawn_blocking; use tauri::async_runtime::spawn_blocking;
use tempfile::NamedTempFile;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum IOError { pub enum IOError {
@ -137,7 +137,8 @@ fn sync_write(
data: impl AsRef<[u8]>, data: impl AsRef<[u8]>,
path: impl AsRef<Path>, path: impl AsRef<Path>,
) -> Result<(), std::io::Error> { ) -> Result<(), std::io::Error> {
let mut tempfile = NamedTempFile::new_in(path.as_ref().parent().ok_or_else(|| { let mut tempfile =
NamedTempFile::new_in(path.as_ref().parent().ok_or_else(|| {
std::io::Error::new( std::io::Error::new(
std::io::ErrorKind::Other, std::io::ErrorKind::Other,
"could not get parent directory for temporary file", "could not get parent directory for temporary file",

View File

@ -12,6 +12,7 @@ pub mod pack;
pub mod process; pub mod process;
pub mod profile; pub mod profile;
pub mod profile_create; pub mod profile_create;
pub mod profile_share;
pub mod settings; pub mod settings;
pub mod tags; pub mod tags;
pub mod utils; pub mod utils;

View File

@ -0,0 +1,97 @@
use crate::api::Result;
use theseus::{
prelude::*,
shared_profile::{SharedProfile, SharedProfileResponse},
};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("profile_share")
.invoke_handler(tauri::generate_handler![
profile_share_get_all,
profile_share_install,
profile_share_create,
profile_share_inbound_sync,
profile_share_outbound_sync,
profile_share_generate_share_link,
profile_share_accept_share_link,
profile_share_remove_users,
profile_share_remove_links,
profile_share_get_link_id,
])
.build()
}
// invoke('plugin:profile_share|profile_share_get_all',profile)
#[tauri::command]
pub async fn profile_share_get_all() -> Result<Vec<SharedProfile>> {
let res = shared_profile::get_all().await?;
Ok(res)
}
#[tauri::command]
pub async fn profile_share_install(
shared_profile_id: String,
) -> Result<ProfilePathId> {
let res = shared_profile::install(shared_profile_id).await?;
Ok(res)
}
#[tauri::command]
pub async fn profile_share_create(path: ProfilePathId) -> Result<()> {
shared_profile::create(path).await?;
Ok(())
}
#[tauri::command]
pub async fn profile_share_inbound_sync(path: ProfilePathId) -> Result<()> {
shared_profile::inbound_sync(path).await?;
Ok(())
}
#[tauri::command]
pub async fn profile_share_outbound_sync(path: ProfilePathId) -> Result<()> {
shared_profile::outbound_sync(path).await?;
Ok(())
}
#[tauri::command]
pub async fn profile_share_generate_share_link(
path: ProfilePathId,
) -> Result<String> {
let res = shared_profile::generate_share_link(path).await?;
Ok(res)
}
#[tauri::command]
pub async fn profile_share_accept_share_link(link: String) -> Result<()> {
shared_profile::accept_share_link(link).await?;
Ok(())
}
// Gets a shared profile from a share link
// This is done without accepting it- so would not include any link information, and is only usable for basic info
#[tauri::command]
pub async fn profile_share_get_link_id(
link: String,
) -> Result<SharedProfileResponse> {
let res = shared_profile::get_from_link(link).await?;
Ok(res)
}
#[tauri::command]
pub async fn profile_share_remove_users(
path: ProfilePathId,
users: Vec<String>,
) -> Result<()> {
shared_profile::remove_shared_profile_users(path, users).await?;
Ok(())
}
#[tauri::command]
pub async fn profile_share_remove_links(
path: ProfilePathId,
links: Vec<String>,
) -> Result<()> {
shared_profile::remove_shared_profile_links(path, links).await?;
Ok(())
}

View File

@ -20,7 +20,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
get_opening_command, get_opening_command,
await_sync, await_sync,
is_offline, is_offline,
refresh_offline refresh_offline,
test_command,
]) ])
.build() .build()
} }
@ -159,6 +160,11 @@ pub async fn get_opening_command() -> Result<Option<CommandPayload>> {
Ok(None) Ok(None)
} }
#[tauri::command]
pub async fn test_command(command: String) -> Result<()> {
handle_command(command).await
}
// helper function called when redirected by a weblink (ie: modrith://do-something) or when redirected by a .mrpack file (in which case its a filepath) // helper function called when redirected by a weblink (ie: modrith://do-something) or when redirected by a .mrpack file (in which case its a filepath)
// We hijack the deep link library (which also contains functionality for instance-checking) // We hijack the deep link library (which also contains functionality for instance-checking)
pub async fn handle_command(command: String) -> Result<()> { pub async fn handle_command(command: String) -> Result<()> {

View File

@ -139,6 +139,7 @@ fn main() {
.plugin(api::process::init()) .plugin(api::process::init())
.plugin(api::profile::init()) .plugin(api::profile::init())
.plugin(api::profile_create::init()) .plugin(api::profile_create::init())
.plugin(api::profile_share::init())
.plugin(api::settings::init()) .plugin(api::settings::init())
.plugin(api::tags::init()) .plugin(api::tags::init())
.plugin(api::utils::init()) .plugin(api::utils::init())

View File

@ -40,12 +40,14 @@ import { TauriEvent } from '@tauri-apps/api/event'
import { await_sync, check_safe_loading_bars_complete } from './helpers/state' import { await_sync, check_safe_loading_bars_complete } from './helpers/state'
import { confirm } from '@tauri-apps/api/dialog' import { confirm } from '@tauri-apps/api/dialog'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue' import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import AcceptSharedProfileModal from '@/components/ui/AcceptSharedProfileModal.vue'
import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue' import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue'
import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue' import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue'
import { install_from_file } from './helpers/pack' import { install_from_file } from './helpers/pack'
const themeStore = useTheming() const themeStore = useTheming()
const urlModal = ref(null) const urlModal = ref(null)
const sharedProfileConfirmModal = ref(null)
const isLoading = ref(true) const isLoading = ref(true)
const videoPlaying = ref(false) const videoPlaying = ref(false)
@ -237,6 +239,9 @@ command_listener(async (e) => {
source: 'CreationModalFileDrop', source: 'CreationModalFileDrop',
}) })
} }
} else if (e.event === 'OpenSharedProfile') {
// Install a shared profile
sharedProfileConfirmModal.value.show(e)
} else { } else {
// Other commands are URL-based (deep linking) // Other commands are URL-based (deep linking)
urlModal.value.show(e) urlModal.value.show(e)
@ -388,6 +393,7 @@ command_listener(async (e) => {
</div> </div>
</div> </div>
<URLConfirmModal ref="urlModal" /> <URLConfirmModal ref="urlModal" />
<AcceptSharedProfileModal ref="sharedProfileConfirmModal" />
<Notifications ref="notificationsWrapper" /> <Notifications ref="notificationsWrapper" />
</template> </template>

View File

@ -169,7 +169,7 @@ const filteredResults = computed(() => {
}) })
} else if (filters.value === 'Downloaded modpacks') { } else if (filters.value === 'Downloaded modpacks') {
instances = instances.filter((instance) => { instances = instances.filter((instance) => {
return instance.metadata?.linked_data return instance.metadata?.linked_data?.modrinth_modpack
}) })
} }

View File

@ -170,7 +170,7 @@ const handleOptionsClick = async (args) => {
break break
case 'install': { case 'install': {
const versions = await useFetch( const versions = await useFetch(
`https://api.modrinth.com/v2/project/${args.item.project_id}/version`, `https://staging-api.modrinth.com/v2/project/${args.item.project_id}/version`,
'project versions' 'project versions'
) )

View File

@ -0,0 +1,79 @@
<script setup>
import { Modal, Button } from 'omorphia'
import { ref } from 'vue'
import { handleError } from '@/store/notifications.js'
import { share_accept, share_install, share_get_link_id } from '@/helpers/shared_profiles.js'
const confirmModal = ref(null)
const linkId = ref(null)
const sharedProfile = ref(null)
defineExpose({
async show(event) {
console.log('showing accept shared profile modal', event)
linkId.value = event.link
sharedProfile.value = await share_get_link_id(linkId.value).catch(handleError)
confirmModal.value.show()
console.log('sharedProfile')
},
})
async function install() {
confirmModal.value.hide()
await share_accept(linkId.value).catch(handleError)
await share_install(sharedProfile.value.id).catch(handleError)
}
</script>
<template>
<Modal ref="confirmModal" :header="`Install ${sharedProfile?.name}`">
<div class="modal-body">
<div class="button-row">
<div class="markdown-body">
<p>
Installing <code>{{ sharedProfile.name }}</code> from user {{ sharedProfile.owner_id }}
</p>
</div>
<div class="button-group">
<Button color="primary" @click="install">Install</Button>
</div>
</div>
</div>
</Modal>
</template>
<style scoped lang="scss">
.modal-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--gap-md);
padding: var(--gap-lg);
}
.button-row {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--gap-md);
}
.button-group {
display: flex;
flex-direction: row;
gap: var(--gap-sm);
}
.project-card {
background-color: var(--color-bg);
width: 100%;
:deep(.badge) {
border: 1px solid var(--color-raised-bg);
background-color: var(--color-accent-contrast);
}
}
</style>

View File

@ -71,7 +71,7 @@ const install = async (e) => {
e?.stopPropagation() e?.stopPropagation()
modLoading.value = true modLoading.value = true
const versions = await useFetch( const versions = await useFetch(
`https://api.modrinth.com/v2/project/${props.instance.project_id}/version`, `https://staging-api.modrinth.com/v2/project/${props.instance.project_id}/version`,
'project versions' 'project versions'
) )
@ -82,7 +82,9 @@ const install = async (e) => {
packs.length === 0 || packs.length === 0 ||
!packs !packs
.map((value) => value.metadata) .map((value) => value.metadata)
.find((pack) => pack.linked_data?.project_id === props.instance.project_id) .find(
(pack) => pack.linked_data?.modrinth_modpack?.project_id === props.instance.project_id
)
) { ) {
modLoading.value = true modLoading.value = true
await pack_install( await pack_install(

View File

@ -247,7 +247,9 @@ const check_valid = computed(() => {
</Button> </Button>
<div <div
v-tooltip=" v-tooltip="
profile.metadata.linked_data?.locked && !profile.installedMod (profile.metadata.linked_data?.modrinth_modpack.locked ||
profile.metadata.linked_data?.shared_profile) &&
!profile.installedMod
? 'Unpair or unlock an instance to add mods.' ? 'Unpair or unlock an instance to add mods.'
: '' : ''
" "
@ -265,7 +267,8 @@ const check_valid = computed(() => {
? 'Installing...' ? 'Installing...'
: profile.installedMod : profile.installedMod
? 'Installed' ? 'Installed'
: profile.metadata.linked_data && profile.metadata.linked_data.locked : profile.metadata.linked_data?.modrinth_modpack.locked ||
profile.metadata.linked_data?.shared_profile
? 'Paired' ? 'Paired'
: 'Install' : 'Install'
}} }}

View File

@ -28,7 +28,9 @@ const filteredVersions = computed(() => {
}) })
const modpackVersionModal = ref(null) const modpackVersionModal = ref(null)
const installedVersion = computed(() => props.instance?.metadata?.linked_data?.version_id) const installedVersion = computed(
() => props.instance?.metadata?.linked_data?.modrinth_modpack?.version_id
)
const installing = computed(() => props.instance.install_stage !== 'installed') const installing = computed(() => props.instance.install_stage !== 'installed')
const inProgress = ref(false) const inProgress = ref(false)
@ -49,7 +51,7 @@ const switchVersion = async (versionId) => {
:noblur="!themeStore.advancedRendering" :noblur="!themeStore.advancedRendering"
> >
<div class="modal-body"> <div class="modal-body">
<Card v-if="instance.metadata.linked_data" class="mod-card"> <Card v-if="instance.metadata.linked_data?.modrinth_modpack" class="mod-card">
<div class="table"> <div class="table">
<div class="table-row with-columns table-head"> <div class="table-row with-columns table-head">
<div class="table-cell table-text download-cell" /> <div class="table-cell table-text download-cell" />

View File

@ -73,7 +73,7 @@ const install = async (e) => {
e?.stopPropagation() e?.stopPropagation()
installing.value = true installing.value = true
const versions = await useFetch( const versions = await useFetch(
`https://api.modrinth.com/v2/project/${props.project.project_id}/version`, `https://staging-api.modrinth.com/v2/project/${props.project.project_id}/version`,
'project versions' 'project versions'
) )
@ -84,7 +84,7 @@ const install = async (e) => {
packs.length === 0 || packs.length === 0 ||
!packs !packs
.map((value) => value.metadata) .map((value) => value.metadata)
.find((pack) => pack.linked_data?.project_id === props.project.project_id) .find((pack) => pack.linked_data?.modrinth_modpack?.project_id === props.project.project_id)
) { ) {
installing.value = true installing.value = true
await pack_install( await pack_install(

View File

@ -135,7 +135,7 @@ const installed = ref(props.installed)
async function install() { async function install() {
installing.value = true installing.value = true
const versions = await useFetch( const versions = await useFetch(
`https://api.modrinth.com/v2/project/${props.project.project_id}/version`, `https://staging-api.modrinth.com/v2/project/${props.project.project_id}/version`,
'project versions' 'project versions'
) )
let queuedVersionData let queuedVersionData
@ -156,7 +156,7 @@ async function install() {
packs.length === 0 || packs.length === 0 ||
!packs !packs
.map((value) => value.metadata) .map((value) => value.metadata)
.find((pack) => pack.linked_data?.project_id === props.project.project_id) .find((pack) => pack.linked_data?.modrinth_modpack?.project_id === props.project.project_id)
) { ) {
await packInstall( await packInstall(
props.project.project_id, props.project.project_id,

View File

@ -20,20 +20,24 @@ defineExpose({
async show(event) { async show(event) {
if (event.event === 'InstallVersion') { if (event.event === 'InstallVersion') {
version.value = await useFetch( version.value = await useFetch(
`https://api.modrinth.com/v2/version/${encodeURIComponent(event.id)}`, `https://staging-api.modrinth.com/v2/version/${encodeURIComponent(event.id)}`,
'version' 'version'
) )
project.value = await useFetch( project.value = await useFetch(
`https://api.modrinth.com/v2/project/${encodeURIComponent(version.value.project_id)}`, `https://staging-api.modrinth.com/v2/project/${encodeURIComponent(
version.value.project_id
)}`,
'project' 'project'
) )
} else { } else {
project.value = await useFetch( project.value = await useFetch(
`https://api.modrinth.com/v2/project/${encodeURIComponent(event.id)}`, `https://staging-api.modrinth.com/v2/project/${encodeURIComponent(event.id)}`,
'project' 'project'
) )
version.value = await useFetch( version.value = await useFetch(
`https://api.modrinth.com/v2/version/${encodeURIComponent(project.value.versions[0])}`, `https://staging-api.modrinth.com/v2/version/${encodeURIComponent(
project.value.versions[0]
)}`,
'version' 'version'
) )
} }

View File

@ -0,0 +1,58 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
/// Created shared modpack from profile
export async function share_create(path) {
return await invoke('plugin:profile_share|profile_share_create', { path })
}
/// Generates a shared profile link
export async function share_generate(path) {
return await invoke('plugin:profile_share|profile_share_generate_share_link', { path })
}
/// Gets the shared profile from the link id
// This is done without accepting it- so would not include any link information, and is only usable for basic info
export async function share_get_link_id(link) {
return await invoke('plugin:profile_share|profile_share_get_link_id', { link })
}
/// Accepts a shared profile link
export async function share_accept(link) {
return await invoke('plugin:profile_share|profile_share_accept_share_link', { link })
}
/// Removes users from a shared profile
export async function remove_users(path, users) {
return await invoke('plugin:profile_share|profile_share_remove_users', { path, users })
}
/// Removes links from a shared profile
export async function remove_links(path, links) {
return await invoke('plugin:profile_share|profile_share_remove_links', { path, links })
}
/// Install a pack from a shared profile id
export async function share_install(sharedProfileId) {
return await invoke('plugin:profile_share|profile_share_install', { sharedProfileId })
}
// get all user profiles that are available to the currentt user
export async function get_all(path) {
return await invoke('plugin:profile_share|profile_share_get_all', { path })
}
// syncs profile to match that on server
export async function inbound_sync(path) {
return await invoke('plugin:profile_share|profile_share_inbound_sync', { path })
}
// syncs profile to update server
// only allowed if profile is owned by user
export async function outbound_sync(path) {
return await invoke('plugin:profile_share|profile_share_outbound_sync', { path })
}

View File

@ -68,7 +68,7 @@ export const installVersionDependencies = async (profile, version) => {
) )
continue continue
const depVersions = await useFetch( const depVersions = await useFetch(
`https://api.modrinth.com/v2/project/${dep.project_id}/version`, `https://staging-api.modrinth.com/v2/project/${dep.project_id}/version`,
'dependency versions' 'dependency versions'
) )
const latest = depVersions.find( const latest = depVersions.find(

View File

@ -29,10 +29,15 @@ const raw_invoke = async (plugin, fn, args) => {
return await invoke('plugin:' + plugin + '|' + fn, args) return await invoke('plugin:' + plugin + '|' + fn, args)
} }
} }
const test_command = async (command) => {
return await raw_invoke('utils', 'test_command', { command })
}
isDev() isDev()
.then((dev) => { .then((dev) => {
if (dev) { if (dev) {
window.raw_invoke = raw_invoke window.raw_invoke = raw_invoke
window.test_command = test_command
} }
}) })
.catch((err) => { .catch((err) => {

View File

@ -149,7 +149,7 @@ if (route.query.ai) {
} }
async function refreshSearch() { async function refreshSearch() {
const base = 'https://api.modrinth.com/v2/' const base = 'https://staging-api.modrinth.com/v2/'
const params = [`limit=${maxResults.value}`, `index=${sortType.value.name}`] const params = [`limit=${maxResults.value}`, `index=${sortType.value.name}`]
if (query.value.length > 0) { if (query.value.length > 0) {

View File

@ -31,8 +31,10 @@ const getInstances = async () => {
let filters = [] let filters = []
for (const instance of recentInstances.value) { for (const instance of recentInstances.value) {
if (instance.metadata.linked_data && instance.metadata.linked_data.project_id) { if (instance.metadata.linked_data?.modrinth_modpack?.project_id) {
filters.push(`NOT"project_id"="${instance.metadata.linked_data.project_id}"`) filters.push(
`NOT"project_id"="${instance.metadata.linked_data?.modrinth_modpack?.project_id}"`
)
} }
} }
filter.value = filters.join(' AND ') filter.value = filters.join(' AND ')
@ -40,7 +42,7 @@ const getInstances = async () => {
const getFeaturedModpacks = async () => { const getFeaturedModpacks = async () => {
const response = await useFetch( const response = await useFetch(
`https://api.modrinth.com/v2/search?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${filter.value}`, `https://staging-api.modrinth.com/v2/search?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${filter.value}`,
'featured modpacks', 'featured modpacks',
offline.value offline.value
) )
@ -52,7 +54,7 @@ const getFeaturedModpacks = async () => {
} }
const getFeaturedMods = async () => { const getFeaturedMods = async () => {
const response = await useFetch( const response = await useFetch(
'https://api.modrinth.com/v2/search?facets=[["project_type:mod"]]&limit=10&index=follows', 'https://staging-api.modrinth.com/v2/search?facets=[["project_type:mod"]]&limit=10&index=follows',
'featured mods', 'featured mods',
offline.value offline.value
) )

View File

@ -209,9 +209,9 @@ const checkProcess = async () => {
// Get information on associated modrinth versions, if any // Get information on associated modrinth versions, if any
const modrinthVersions = ref([]) const modrinthVersions = ref([])
if (!(await isOffline()) && instance.value.metadata.linked_data?.project_id) { if (!(await isOffline()) && instance.value.metadata.linked_data?.modrinth_modpack?.project_id) {
modrinthVersions.value = await useFetch( modrinthVersions.value = await useFetch(
`https://api.modrinth.com/v2/project/${instance.value.metadata.linked_data.project_id}/version`, `https://staging-api.modrinth.com/v2/project/${instance.value.metadata.linked_data?.modrinth_modpack?.project_id}/version`,
'project' 'project'
) )
} }

View File

@ -359,7 +359,7 @@
/> />
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" /> <ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ModpackVersionModal <ModpackVersionModal
v-if="instance.metadata.linked_data" v-if="instance.metadata.linked_data?.modrinth_modpack"
ref="modpackVersionModal" ref="modpackVersionModal"
:instance="instance" :instance="instance"
:versions="props.versions" :versions="props.versions"
@ -443,11 +443,18 @@ const projects = ref([])
const selectionMap = ref(new Map()) const selectionMap = ref(new Map())
const showingOptions = ref(false) const showingOptions = ref(false)
const isPackLocked = computed(() => { const isPackLocked = computed(() => {
return props.instance.metadata.linked_data && props.instance.metadata.linked_data.locked if (props.instance.metadata.linked_data?.shared_profile) {
return !props.instance.metadata.linked_data.shared_profile.is_owner
}
return props.instance.metadata.linked_data?.modrinth_modpack?.locked
}) })
const canUpdatePack = computed(() => { const canUpdatePack = computed(() => {
if (!props.instance.metadata.linked_data) return false if (!props.instance.metadata.linked_data) return false
return props.instance.metadata.linked_data.version_id !== props.instance.modrinth_update_version let linked_data = props.instance.metadata.linked_data
if (linked_data.modrinth_modpack) {
return linked_data.modrinth_modpack.version_id !== props.instance.sync_update_version
}
return false
}) })
const exportModal = ref(null) const exportModal = ref(null)

View File

@ -358,7 +358,7 @@
/> />
</div> </div>
</Card> </Card>
<Card v-if="instance.metadata.linked_data"> <Card v-if="instance.metadata.linked_data?.modrinth_modpack">
<div class="label"> <div class="label">
<h3> <h3>
<span class="label__title size-card-header">Modpack</span> <span class="label__title size-card-header">Modpack</span>
@ -413,8 +413,10 @@
<XIcon /> Unpair <XIcon /> Unpair
</Button> </Button>
</div> </div>
<div
<div v-if="props.instance.metadata.linked_data.project_id" class="adjacent-input"> v-if="props.instance.metadata.linked_data?.modrinth_modpack?.project_id"
class="adjacent-input"
>
<label for="change-modpack-version"> <label for="change-modpack-version">
<span class="label__title">Change modpack version</span> <span class="label__title">Change modpack version</span>
<span class="label__description"> <span class="label__description">
@ -445,6 +447,96 @@
</Button> </Button>
</div> </div>
</Card> </Card>
<Card v-if="installedSharedProfileData">
<div class="label">
<h3>
<span class="label__title size-card-header">Shared profile management</span>
</h3>
</div>
<div v-if="installedSharedProfileData.is_owned" class="adjacent-input">
<label for="share-links">
<span class="label__title">Generate share link</span>
<span class="label__description">
Creates a share link to share this modpack with others. This allows them to install your
instance, as well as stay up to date with any changes you make.
</span>
</label>
<Button id="share-links" @click="generateShareLink"> <GlobeIcon /> Share </Button>
</div>
<div v-if="shareLink" class="adjacent-input">
Generated link: <code>{{ shareLink }}</code>
</div>
<div v-if="installedSharedProfileData.is_owned" class="table">
<div class="table-row table-head">
<div class="table-cell table-text name-cell actions-cell">
<Button class="transparent"> Name </Button>
</div>
</div>
<div v-for="user in installedSharedProfileData.users" :key="user" class="table-row">
<div class="table-cell table-text name-cell">
<div class="user-content">
<span v-tooltip="`${user}`" class="title">{{ user }}</span>
</div>
</div>
<div class="table-cell table-text manage">
<div v-tooltip="'Remove user'">
<Button
icon-only
@click="removeSharedPackUser(user)"
:disabled="user === installedSharedProfileData.owner_id"
>
<TrashIcon />
</Button>
</div>
</div>
</div>
</div>
<div v-if="installedSharedProfileData.is_owned" class="adjacent-input">
<label for="share-sync">
<span class="label__title">Sync shared profile</span>
<span class="label__description" v-if="props.instance.sync_update_version?.is_synced">
You are up to date with the shared profile.
</span>
<span class="label__description" v-else>
You have changes that have not been synced to the shared profile. Click the button to
upload your changes.
</span>
</label>
<Button
id="share-sync-sync"
@click="outboundSyncSharedProfile"
:disabled="props.instance.sync_update_version?.is_synced"
>
<GlobeIcon /> Sync
</Button>
<Button
id="share-sync-revert"
@click="inboundSyncSharedProfile"
:disabled="props.instance.sync_update_version?.is_synced"
>
<GlobeIcon /> Revert
</Button>
</div>
<div v-else class="adjacent-input">
<label for="share-sync">
<span class="label__title">Sync shared profile</span>
<span class="label__description" v-if="props.instance.sync_update_version?.is_synced">
You are up to date with the shared profile.
</span>
<span class="label__description" v-else>
You are not up to date with the shared profile. Click the button to update your instance.
</span>
</label>
<Button
id="share-sync-sync"
@click="inboundSyncSharedProfile"
:disabled="props.instance.sync_update_version?.is_synced"
>
<GlobeIcon /> Sync
</Button>
</div>
{{ props.instance.sync_update_version }}
</Card>
<Card> <Card>
<div class="label"> <div class="label">
<h3> <h3>
@ -502,7 +594,7 @@
</div> </div>
</Card> </Card>
<ModpackVersionModal <ModpackVersionModal
v-if="instance.metadata.linked_data" v-if="instance.metadata.linked_data?.modrinth_modpack"
ref="modpackVersionModal" ref="modpackVersionModal"
:instance="instance" :instance="instance"
:versions="props.versions" :versions="props.versions"
@ -527,6 +619,7 @@ import {
HammerIcon, HammerIcon,
ModalConfirm, ModalConfirm,
DownloadIcon, DownloadIcon,
GlobeIcon,
ClipboardCopyIcon, ClipboardCopyIcon,
Button, Button,
Toggle, Toggle,
@ -545,6 +638,13 @@ import {
remove, remove,
update_repair_modrinth, update_repair_modrinth,
} from '@/helpers/profile.js' } from '@/helpers/profile.js'
import {
get_all,
outbound_sync,
inbound_sync,
share_generate,
remove_users,
} from '@/helpers/shared_profiles.js'
import { computed, readonly, ref, shallowRef, watch } from 'vue' import { computed, readonly, ref, shallowRef, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre.js' import { get_max_memory } from '@/helpers/jre.js'
import { get } from '@/helpers/settings.js' import { get } from '@/helpers/settings.js'
@ -659,12 +759,22 @@ const unlinkModpack = ref(false)
const inProgress = ref(false) const inProgress = ref(false)
const installing = computed(() => props.instance.install_stage !== 'installed') const installing = computed(() => props.instance.install_stage !== 'installed')
const installedVersion = computed(() => props.instance?.metadata?.linked_data?.version_id) const installedVersion = computed(
() => props.instance?.metadata?.linked_data?.modrinth_modpack?.version_id
)
const installedVersionData = computed(() => { const installedVersionData = computed(() => {
if (!installedVersion.value) return null if (!installedVersion.value) return null
return props.versions.find((version) => version.id === installedVersion.value) return props.versions.find((version) => version.id === installedVersion.value)
}) })
const sharedProfiles = await get_all()
const installedSharedProfileData = computed(() => {
if (!props.instance.metadata.linked_data?.shared_profile) return null
return sharedProfiles.find(
(profile) => profile.id === props.instance.metadata.linked_data?.shared_profile?.profile_id
)
})
watch( watch(
[ [
title, title,
@ -794,13 +904,13 @@ async function unpairProfile() {
async function unlockProfile() { async function unlockProfile() {
const editProfile = props.instance const editProfile = props.instance
editProfile.metadata.linked_data.locked = false editProfile.metadata.linked_data.modrinth_modpack.locked = false
await edit(props.instance.path, editProfile) await edit(props.instance.path, editProfile)
modalConfirmUnlock.value.hide() modalConfirmUnlock.value.hide()
} }
const isPackLocked = computed(() => { const isPackLocked = computed(() => {
return props.instance.metadata.linked_data && props.instance.metadata.linked_data.locked return props.instance.metadata.linked_data?.modrinth_modpack.locked
}) })
async function repairModpack() { async function repairModpack() {
@ -932,6 +1042,23 @@ async function saveGvLoaderEdits() {
editing.value = false editing.value = false
changeVersionsModal.value.hide() changeVersionsModal.value.hide()
} }
async function outboundSyncSharedProfile() {
await outbound_sync(props.instance.path).catch(handleError)
}
async function inboundSyncSharedProfile() {
await inbound_sync(props.instance.path).catch(handleError)
}
const shareLink = ref(null)
async function generateShareLink() {
shareLink.value = await share_generate(props.instance.path).catch(handleError)
}
async function removeSharedPackUser(user) {
await remove_users(props.instance.path, [user]).catch(handleError)
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -1012,4 +1139,29 @@ async function saveGvLoaderEdits() {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
} }
.table {
margin-block-start: 0;
border-radius: var(--radius-lg);
border: 2px solid var(--color-bg);
}
.table-row {
grid-template-columns: 7fr 1fr;
}
.table-cell {
align-items: center;
}
.user-content {
display: flex;
align-items: center;
gap: 1rem;
.title {
color: var(--color-contrast);
font-weight: bolder;
margin-left: 1rem;
}
}
</style> </style>

View File

@ -314,10 +314,13 @@ async function fetchProjectData() {
categories.value, categories.value,
instance.value, instance.value,
] = await Promise.all([ ] = await Promise.all([
useFetch(`https://api.modrinth.com/v2/project/${route.params.id}`, 'project'), useFetch(`https://staging-api.modrinth.com/v2/project/${route.params.id}`, 'project'),
useFetch(`https://api.modrinth.com/v2/project/${route.params.id}/version`, 'project'), useFetch(`https://staging-api.modrinth.com/v2/project/${route.params.id}/version`, 'project'),
useFetch(`https://api.modrinth.com/v2/project/${route.params.id}/members`, 'project'), useFetch(`https://staging-api.modrinth.com/v2/project/${route.params.id}/members`, 'project'),
useFetch(`https://api.modrinth.com/v2/project/${route.params.id}/dependencies`, 'project'), useFetch(
`https://staging-api.modrinth.com/v2/project/${route.params.id}/dependencies`,
'project'
),
get_categories().catch(handleError), get_categories().catch(handleError),
route.query.i ? getInstance(route.query.i, false).catch(handleError) : Promise.resolve(), route.query.i ? getInstance(route.query.i, false).catch(handleError) : Promise.resolve(),
]) ])
@ -391,7 +394,7 @@ async function install(version) {
packs.length === 0 || packs.length === 0 ||
!packs !packs
.map((value) => value.metadata) .map((value) => value.metadata)
.find((pack) => pack.linked_data?.project_id === data.value.id) .find((pack) => pack.linked_data?.modrinth_modpack?.project_id === data.value.id)
) { ) {
await packInstall( await packInstall(
data.value.id, data.value.id,