Compare commits
20 Commits
v0.6.1
...
shared-pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e147cc9db | ||
|
|
d2834ce720 | ||
|
|
f9beea8ef2 | ||
|
|
1018d05e36 | ||
|
|
719aded698 | ||
|
|
6b99f82cea | ||
|
|
bdde054036 | ||
|
|
0d3f007dd4 | ||
|
|
9702dae19d | ||
|
|
f6a697780b | ||
|
|
ef8b525376 | ||
|
|
e39635c75b | ||
|
|
260744c8af | ||
|
|
54114e6e94 | ||
|
|
1bd721d523 | ||
|
|
c1518c52f3 | ||
|
|
531b38e562 | ||
|
|
fd299aabe8 | ||
|
|
4b1a3eb41e | ||
|
|
a5739fa7e2 |
26
Cargo.lock
generated
26
Cargo.lock
generated
@@ -2498,6 +2498,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "minidump-common"
|
||||
version = "0.14.0"
|
||||
@@ -3582,6 +3592,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
@@ -4685,7 +4696,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus"
|
||||
version = "0.6.1"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"async-tungstenite",
|
||||
@@ -4733,7 +4744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_cli"
|
||||
version = "0.6.1"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"argh",
|
||||
"color-eyre",
|
||||
@@ -4760,7 +4771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_gui"
|
||||
version = "0.6.1"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cocoa",
|
||||
@@ -5237,6 +5248,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
|
||||
dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.13"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus"
|
||||
version = "0.6.1"
|
||||
version = "0.6.3"
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
@@ -46,7 +46,7 @@ indicatif = { version = "0.17.3", optional = true }
|
||||
|
||||
async-tungstenite = { version = "0.22.1", features = ["tokio-runtime", "tokio-native-tls"] }
|
||||
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-stream = { version = "0.1", features = ["fs"] }
|
||||
async-recursion = "1.0.4"
|
||||
|
||||
@@ -14,6 +14,9 @@ use crate::{
|
||||
/// (Does not include modrinth://)
|
||||
pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
|
||||
Ok(match sublink.split_once('/') {
|
||||
Some(("shared_profile", link)) => CommandPayload::OpenSharedProfile {
|
||||
link: link.to_string(),
|
||||
},
|
||||
// /mod/{id} - Installs a mod of mod id
|
||||
Some(("mod", id)) => CommandPayload::InstallMod { id: id.to_string() },
|
||||
// /version/{id} - Installs a specific version of id
|
||||
|
||||
@@ -41,7 +41,7 @@ pub async fn wait_finish(device_code: String) -> crate::Result<Credentials> {
|
||||
}
|
||||
xsts_token::XSTSResponse::Success { token: xsts_token } => {
|
||||
// Get xsts bearer token from xsts token
|
||||
let bearer_token =
|
||||
let (bearer_token, expires_in) =
|
||||
bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
@@ -63,8 +63,7 @@ pub async fn wait_finish(device_code: String) -> crate::Result<Credentials> {
|
||||
player_info.name,
|
||||
bearer_token,
|
||||
oauth.refresh_token,
|
||||
chrono::Utc::now()
|
||||
+ chrono::Duration::seconds(oauth.expires_in),
|
||||
chrono::Utc::now() + chrono::Duration::seconds(expires_in),
|
||||
);
|
||||
|
||||
// Put credentials into state
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
//! Login route for Hydra, redirects to the Microsoft login page before going to the redirect route
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{hydra::MicrosoftError, util::fetch::REQWEST_CLIENT};
|
||||
@@ -19,17 +17,21 @@ pub struct DeviceLoginSuccess {
|
||||
|
||||
pub async fn init() -> crate::Result<DeviceLoginSuccess> {
|
||||
// Get the initial URL
|
||||
let client_id = MICROSOFT_CLIENT_ID;
|
||||
|
||||
// Get device code
|
||||
// Define the parameters
|
||||
let mut params = HashMap::new();
|
||||
params.insert("client_id", client_id);
|
||||
params.insert("scope", "XboxLive.signin offline_access");
|
||||
|
||||
// urlencoding::encode("XboxLive.signin offline_access"));
|
||||
let resp = auth_retry(|| REQWEST_CLIENT.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded").form(¶ms).send()).await?;
|
||||
let resp = auth_retry(|| REQWEST_CLIENT.get("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
|
||||
.header("Content-Length", "0")
|
||||
.query(&[
|
||||
("client_id", MICROSOFT_CLIENT_ID),
|
||||
(
|
||||
"scope",
|
||||
"XboxLive.signin XboxLive.offline_access profile openid email",
|
||||
),
|
||||
])
|
||||
.send()
|
||||
).await?;
|
||||
|
||||
match resp.status() {
|
||||
reqwest::StatusCode::OK => Ok(resp.json().await?),
|
||||
|
||||
@@ -24,6 +24,10 @@ pub async fn refresh(refresh_token: String) -> crate::Result<OauthSuccess> {
|
||||
params.insert("grant_type", "refresh_token");
|
||||
params.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||
params.insert("refresh_token", &refresh_token);
|
||||
params.insert(
|
||||
"redirect_uri",
|
||||
"https://login.microsoftonline.com/common/oauth2/nativeclient",
|
||||
);
|
||||
|
||||
// Poll the URL in a loop until we are successful.
|
||||
// On an authorization_pending response, wait 5 seconds and try again.
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const MCSERVICES_AUTH_URL: &str =
|
||||
"https://api.minecraftservices.com/launcher/login";
|
||||
"https://api.minecraftservices.com/authentication/login_with_xbox";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BearerTokenResponse {
|
||||
access_token: String,
|
||||
expires_in: i64,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn fetch_bearer(token: &str, uhs: &str) -> crate::Result<String> {
|
||||
pub async fn fetch_bearer(
|
||||
token: &str,
|
||||
uhs: &str,
|
||||
) -> crate::Result<(String, i64)> {
|
||||
let body = auth_retry(|| {
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(MCSERVICES_AUTH_URL)
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({
|
||||
"xtoken": format!("XBL3.0 x={};{}", uhs, token),
|
||||
"platform": "PC_LAUNCHER"
|
||||
"identityToken": format!("XBL3.0 x={};{}", uhs, token),
|
||||
}))
|
||||
.send()
|
||||
})
|
||||
@@ -21,14 +31,12 @@ pub async fn fetch_bearer(token: &str, uhs: &str) -> crate::Result<String> {
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(&body)?
|
||||
.get("access_token")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(String::from)
|
||||
.ok_or(
|
||||
serde_json::from_str::<BearerTokenResponse>(&body)
|
||||
.map(|x| (x.access_token, x.expires_in))
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Response didn't contain valid bearer token. body: {body}"
|
||||
))
|
||||
.into(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use futures::Future;
|
||||
use reqwest::Response;
|
||||
|
||||
const RETRY_COUNT: usize = 2; // Does command 3 times
|
||||
const RETRY_COUNT: usize = 9; // Does command 3 times
|
||||
const RETRY_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
pub mod bearer_token;
|
||||
|
||||
@@ -24,6 +24,14 @@ impl Default for PlayerInfo {
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn fetch_info(token: &str) -> crate::Result<PlayerInfo> {
|
||||
auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.get("https://api.minecraftservices.com/entitlements/mcstore")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.get(PROFILE_URL)
|
||||
|
||||
@@ -25,6 +25,10 @@ pub async fn poll_response(device_code: String) -> crate::Result<OauthSuccess> {
|
||||
params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
||||
params.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||
params.insert("device_code", &device_code);
|
||||
params.insert(
|
||||
"scope",
|
||||
"XboxLive.signin XboxLive.offline_access profile openid email",
|
||||
);
|
||||
|
||||
// Poll the URL in a loop until we are successful.
|
||||
// On an authorization_pending response, wait 5 seconds and try again.
|
||||
@@ -34,7 +38,6 @@ pub async fn poll_response(device_code: String) -> crate::Result<OauthSuccess> {
|
||||
.post(
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
|
||||
)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ pub async fn login_xbl(token: &str) -> crate::Result<XBLLogin> {
|
||||
REQWEST_CLIENT
|
||||
.post(XBL_AUTH_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header("x-xbl-contract-version", "1")
|
||||
.json(&json!({
|
||||
"Properties": {
|
||||
"AuthMethod": "RPS",
|
||||
|
||||
@@ -241,14 +241,6 @@ pub async fn get_latest_log_cursor(
|
||||
get_generic_live_log_cursor(profile_path, "latest.log", cursor).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_std_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
cursor: u64, // 0 to start at beginning of file
|
||||
) -> crate::Result<LatestLogCursor> {
|
||||
get_generic_live_log_cursor(profile_path, "latest_stdout.log", cursor).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_generic_live_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod process;
|
||||
pub mod profile;
|
||||
pub mod safety;
|
||||
pub mod settings;
|
||||
pub mod shared_profile;
|
||||
pub mod tags;
|
||||
|
||||
pub mod data {
|
||||
@@ -29,7 +30,7 @@ pub mod prelude {
|
||||
event::CommandPayload,
|
||||
jre, metadata, pack, process,
|
||||
profile::{self, create, Profile},
|
||||
settings,
|
||||
settings, shared_profile,
|
||||
state::JavaGlobals,
|
||||
state::{Dependency, ProfilePathId, ProjectPathId},
|
||||
util::{
|
||||
|
||||
@@ -215,7 +215,7 @@ async fn import_atlauncher_unmanaged(
|
||||
.clone()
|
||||
.unwrap_or_else(|| backup_name.to_string());
|
||||
prof.install_stage = ProfileInstallStage::PackInstalling;
|
||||
prof.metadata.linked_data = Some(LinkedData {
|
||||
prof.metadata.linked_data = Some(LinkedData::ModrinthModpack {
|
||||
project_id: description.project_id.clone(),
|
||||
version_id: description.version_id.clone(),
|
||||
locked: Some(
|
||||
|
||||
@@ -16,37 +16,22 @@ use crate::{
|
||||
|
||||
use super::{copy_dotminecraft, recache_icon};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlameManifest {
|
||||
pub manifest_version: u8,
|
||||
pub name: String,
|
||||
pub minecraft: FlameMinecraft,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlameMinecraft {
|
||||
pub version: String,
|
||||
pub mod_loaders: Vec<FlameModLoader>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlameModLoader {
|
||||
pub id: String,
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MinecraftInstance {
|
||||
pub name: Option<String>,
|
||||
pub base_mod_loader: Option<MinecraftInstanceModLoader>,
|
||||
pub profile_image_path: Option<PathBuf>,
|
||||
pub installed_modpack: Option<InstalledModpack>,
|
||||
pub game_version: String, // Minecraft game version. Non-prioritized, use this if Vanilla
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
pub struct MinecraftInstanceModLoader {
|
||||
pub name: String,
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstalledModpack {
|
||||
pub thumbnail_url: Option<String>,
|
||||
}
|
||||
@@ -113,35 +98,26 @@ pub async fn import_curseforge(
|
||||
}
|
||||
}
|
||||
|
||||
// Curseforge vanilla profile may not have a manifest.json, so we allow it to not exist
|
||||
if curseforge_instance_folder.join("manifest.json").exists() {
|
||||
// Load manifest.json
|
||||
let cf_manifest: String = io::read_to_string(
|
||||
&curseforge_instance_folder.join("manifest.json"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cf_manifest: FlameManifest =
|
||||
serde_json::from_str::<FlameManifest>(&cf_manifest)?;
|
||||
|
||||
let game_version = cf_manifest.minecraft.version;
|
||||
// base mod loader is always None for vanilla
|
||||
if let Some(instance_mod_loader) = minecraft_instance.base_mod_loader {
|
||||
let game_version = minecraft_instance.game_version;
|
||||
|
||||
// CF allows Forge, Fabric, and Vanilla
|
||||
let mut mod_loader = None;
|
||||
let mut loader_version = None;
|
||||
for loader in cf_manifest.minecraft.mod_loaders {
|
||||
match loader.id.split_once('-') {
|
||||
Some(("forge", version)) => {
|
||||
mod_loader = Some(ModLoader::Forge);
|
||||
loader_version = Some(version.to_string());
|
||||
}
|
||||
Some(("fabric", version)) => {
|
||||
mod_loader = Some(ModLoader::Fabric);
|
||||
loader_version = Some(version.to_string());
|
||||
}
|
||||
_ => {}
|
||||
|
||||
match instance_mod_loader.name.split('-').collect::<Vec<&str>>()[..] {
|
||||
["forge", version] => {
|
||||
mod_loader = Some(ModLoader::Forge);
|
||||
loader_version = Some(version.to_string());
|
||||
}
|
||||
["fabric", version, _game_version] => {
|
||||
mod_loader = Some(ModLoader::Fabric);
|
||||
loader_version = Some(version.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let mod_loader = mod_loader.unwrap_or(ModLoader::Vanilla);
|
||||
|
||||
let loader_version = if mod_loader != ModLoader::Vanilla {
|
||||
@@ -170,7 +146,7 @@ pub async fn import_curseforge(
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
// If no manifest is found, it's a vanilla profile
|
||||
// create a vanilla profile
|
||||
crate::api::profile::edit(&profile_path, |prof| {
|
||||
prof.metadata.name = override_title
|
||||
.clone()
|
||||
|
||||
@@ -301,7 +301,7 @@ pub async fn copy_dotminecraft(
|
||||
#[theseus_macros::debug_pin]
|
||||
#[async_recursion::async_recursion]
|
||||
#[tracing::instrument]
|
||||
async fn get_all_subfiles(src: &Path) -> crate::Result<Vec<PathBuf>> {
|
||||
pub async fn get_all_subfiles(src: &Path) -> crate::Result<Vec<PathBuf>> {
|
||||
if !src.is_dir() {
|
||||
return Ok(vec![src.to_path_buf()]);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ pub fn get_profile_from_pack(
|
||||
} => CreatePackProfile {
|
||||
name: title,
|
||||
icon_url,
|
||||
linked_data: Some(LinkedData {
|
||||
linked_data: Some(LinkedData::ModrinthModpack {
|
||||
project_id: Some(project_id),
|
||||
version_id: Some(version_id),
|
||||
locked: Some(true),
|
||||
@@ -391,21 +391,30 @@ pub async fn set_profile_information(
|
||||
let project_id = description.project_id.clone();
|
||||
let version_id = description.version_id.clone();
|
||||
|
||||
prof.metadata.linked_data = if project_id.is_some()
|
||||
&& version_id.is_some()
|
||||
{
|
||||
Some(LinkedData {
|
||||
project_id,
|
||||
version_id,
|
||||
locked: if !ignore_lock {
|
||||
Some(true)
|
||||
} else {
|
||||
prof.metadata.linked_data.as_ref().and_then(|x| x.locked)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
prof.metadata.linked_data =
|
||||
if project_id.is_some() && version_id.is_some() {
|
||||
Some(LinkedData::ModrinthModpack {
|
||||
project_id,
|
||||
version_id,
|
||||
locked: if !ignore_lock {
|
||||
Some(true)
|
||||
} else {
|
||||
prof.metadata.linked_data.as_ref().and_then(|x| {
|
||||
if let LinkedData::ModrinthModpack {
|
||||
locked: Some(locked),
|
||||
..
|
||||
} = x
|
||||
{
|
||||
Some(*locked)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
prof.metadata.icon = description.icon.clone();
|
||||
prof.metadata.game_version = game_version.clone();
|
||||
|
||||
@@ -102,11 +102,14 @@ pub async fn profile_create(
|
||||
}
|
||||
|
||||
profile.metadata.linked_data = linked_data;
|
||||
if let Some(linked_data) = &mut profile.metadata.linked_data {
|
||||
linked_data.locked = Some(
|
||||
linked_data.project_id.is_some()
|
||||
&& linked_data.version_id.is_some(),
|
||||
);
|
||||
if let Some(LinkedData::ModrinthModpack {
|
||||
project_id,
|
||||
version_id,
|
||||
locked,
|
||||
..
|
||||
}) = &mut profile.metadata.linked_data
|
||||
{
|
||||
*locked = Some(project_id.is_some() && version_id.is_some());
|
||||
}
|
||||
|
||||
emit_profile(
|
||||
@@ -272,8 +275,8 @@ pub(crate) async fn get_loader_version_from_loader(
|
||||
|
||||
let loader_version = loaders
|
||||
.iter()
|
||||
.find(|&x| filter(x))
|
||||
.cloned()
|
||||
.find(filter)
|
||||
.or(
|
||||
// If stable was searched for but not found, return latest by default
|
||||
if version == "stable" {
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::pack::install_from::{
|
||||
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
|
||||
};
|
||||
use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId};
|
||||
use crate::state::LinkedData;
|
||||
use crate::state::{InnerProjectPathUnix, ProjectMetadata, SideType};
|
||||
|
||||
use crate::util::fetch;
|
||||
@@ -67,13 +68,11 @@ pub async fn get(
|
||||
let state = State::get().await?;
|
||||
let profiles = state.profiles.read().await;
|
||||
let mut profile = profiles.0.get(path).cloned();
|
||||
|
||||
if clear_projects.unwrap_or(false) {
|
||||
if let Some(profile) = &mut profile {
|
||||
profile.projects = HashMap::new();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
@@ -117,15 +116,14 @@ pub async fn get_mod_full_path(
|
||||
project_path: &ProjectPathId,
|
||||
) -> crate::Result<PathBuf> {
|
||||
if get(profile_path, Some(true)).await?.is_some() {
|
||||
let full_path = io::canonicalize(
|
||||
project_path.get_full_path(profile_path.clone()).await?,
|
||||
)?;
|
||||
let full_path =
|
||||
io::canonicalize(project_path.get_full_path(profile_path).await?)?;
|
||||
return Ok(full_path);
|
||||
}
|
||||
|
||||
Err(crate::ErrorKind::OtherError(format!(
|
||||
"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())
|
||||
}
|
||||
@@ -874,7 +872,13 @@ pub async fn try_update_playtime(path: &ProfilePathId) -> crate::Result<()> {
|
||||
let res = if updated_recent_playtime > 0 {
|
||||
// Create update struct to send to Labrinth
|
||||
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!({
|
||||
"seconds": updated_recent_playtime,
|
||||
"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;
|
||||
fetch::post_json(
|
||||
"https://api.modrinth.com/analytics/playtime",
|
||||
"https://staging-api.modrinth.com/analytics/playtime",
|
||||
serde_json::to_value(hashmap)?,
|
||||
&state.fetch_semaphore,
|
||||
&creds,
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
pack::{self, install_from::generate_pack_from_version_id},
|
||||
prelude::{ProfilePathId, ProjectPathId},
|
||||
profile::get,
|
||||
state::{ProfileInstallStage, Project},
|
||||
state::{LinkedData, ProfileInstallStage, Project},
|
||||
LoadingBarType, State,
|
||||
};
|
||||
use futures::try_join;
|
||||
@@ -30,15 +30,14 @@ pub async fn update_managed_modrinth_version(
|
||||
};
|
||||
|
||||
// Extract modrinth pack information, if appropriate
|
||||
let linked_data = profile
|
||||
.metadata
|
||||
.linked_data
|
||||
.as_ref()
|
||||
.ok_or_else(unmanaged_err)?;
|
||||
let project_id: &String =
|
||||
linked_data.project_id.as_ref().ok_or_else(unmanaged_err)?;
|
||||
let version_id =
|
||||
linked_data.version_id.as_ref().ok_or_else(unmanaged_err)?;
|
||||
let Some(LinkedData::ModrinthModpack {
|
||||
project_id: Some(ref project_id),
|
||||
version_id: Some(ref version_id),
|
||||
..
|
||||
}) = profile.metadata.linked_data
|
||||
else {
|
||||
return Err(unmanaged_err().into());
|
||||
};
|
||||
|
||||
// Replace the pack with the new version
|
||||
replace_managed_modrinth(
|
||||
@@ -107,15 +106,14 @@ pub async fn repair_managed_modrinth(
|
||||
.await?;
|
||||
|
||||
// Extract modrinth pack information, if appropriate
|
||||
let linked_data = profile
|
||||
.metadata
|
||||
.linked_data
|
||||
.as_ref()
|
||||
.ok_or_else(unmanaged_err)?;
|
||||
let project_id: &String =
|
||||
linked_data.project_id.as_ref().ok_or_else(unmanaged_err)?;
|
||||
let version_id =
|
||||
linked_data.version_id.as_ref().ok_or_else(unmanaged_err)?;
|
||||
let Some(LinkedData::ModrinthModpack {
|
||||
project_id: Some(ref project_id),
|
||||
version_id: Some(ref version_id),
|
||||
..
|
||||
}) = profile.metadata.linked_data
|
||||
else {
|
||||
return Err(unmanaged_err().into());
|
||||
};
|
||||
|
||||
// Replace the pack with the same version
|
||||
replace_managed_modrinth(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Theseus profile management interface
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
use io::IOError;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -9,7 +10,7 @@ use crate::{
|
||||
event::emit::{emit_loading, init_loading},
|
||||
prelude::DirectoryInfo,
|
||||
state::{self, Profiles},
|
||||
util::io,
|
||||
util::{fetch, io},
|
||||
};
|
||||
pub use crate::{
|
||||
state::{
|
||||
@@ -76,6 +77,7 @@ pub async fn set(settings: Settings) -> crate::Result<()> {
|
||||
/// Sets the new config dir, the location of all Theseus data except for the settings.json and caches
|
||||
/// Takes control of the entire state and blocks until completion
|
||||
pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
|
||||
tracing::trace!("Changing config dir to: {}", new_config_dir.display());
|
||||
if !new_config_dir.is_dir() {
|
||||
return Err(crate::ErrorKind::FSError(format!(
|
||||
"New config dir is not a folder: {}",
|
||||
@@ -84,6 +86,14 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
|
||||
.as_error());
|
||||
}
|
||||
|
||||
if !is_dir_writeable(new_config_dir.clone()).await? {
|
||||
return Err(crate::ErrorKind::FSError(format!(
|
||||
"New config dir is not writeable: {}",
|
||||
new_config_dir.display()
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
|
||||
let loading_bar = init_loading(
|
||||
crate::LoadingBarType::ConfigChange {
|
||||
new_path: new_config_dir.clone(),
|
||||
@@ -99,6 +109,54 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
|
||||
let old_config_dir =
|
||||
state_write.directories.config_dir.read().await.clone();
|
||||
|
||||
// Reset file watcher
|
||||
tracing::trace!("Reset file watcher");
|
||||
let file_watcher = state::init_watcher().await?;
|
||||
state_write.file_watcher = RwLock::new(file_watcher);
|
||||
|
||||
// Getting files to be moved
|
||||
let mut config_entries = io::read_dir(&old_config_dir).await?;
|
||||
let across_drives = is_different_drive(&old_config_dir, &new_config_dir);
|
||||
let mut entries = vec![];
|
||||
let mut deletable_entries = vec![];
|
||||
while let Some(entry) = config_entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &old_config_dir))?
|
||||
{
|
||||
let entry_path = entry.path();
|
||||
if let Some(file_name) = entry_path.file_name() {
|
||||
// We are only moving the profiles and metadata folders
|
||||
if file_name == state::PROFILES_FOLDER_NAME
|
||||
|| file_name == state::METADATA_FOLDER_NAME
|
||||
{
|
||||
if across_drives {
|
||||
entries.extend(
|
||||
crate::pack::import::get_all_subfiles(&entry_path)
|
||||
.await?,
|
||||
);
|
||||
deletable_entries.push(entry_path.clone());
|
||||
} else {
|
||||
entries.push(entry_path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::trace!("Moving files");
|
||||
let semaphore = &state_write.io_semaphore;
|
||||
let num_entries = entries.len() as f64;
|
||||
for entry_path in entries {
|
||||
let relative_path = entry_path.strip_prefix(&old_config_dir)?;
|
||||
let new_path = new_config_dir.join(relative_path);
|
||||
if across_drives {
|
||||
fetch::copy(&entry_path, &new_path, semaphore).await?;
|
||||
} else {
|
||||
io::rename(entry_path.clone(), new_path.clone()).await?;
|
||||
}
|
||||
emit_loading(&loading_bar, 80.0 * (1.0 / num_entries), None).await?;
|
||||
}
|
||||
|
||||
tracing::trace!("Setting configuration setting");
|
||||
// Set load config dir setting
|
||||
let settings = {
|
||||
@@ -131,41 +189,20 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
|
||||
tracing::trace!("Reinitializing directory");
|
||||
// Set new state information
|
||||
state_write.directories = DirectoryInfo::init(&settings)?;
|
||||
let total_entries = std::fs::read_dir(&old_config_dir)
|
||||
.map_err(|e| IOError::with_path(e, &old_config_dir))?
|
||||
.count() as f64;
|
||||
|
||||
// Move all files over from state_write.directories.config_dir to new_config_dir
|
||||
tracing::trace!("Renaming folder structure");
|
||||
let mut i = 0.0;
|
||||
let mut entries = io::read_dir(&old_config_dir).await?;
|
||||
while let Some(entry) = entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &old_config_dir))?
|
||||
{
|
||||
let entry_path = entry.path();
|
||||
if let Some(file_name) = entry_path.file_name() {
|
||||
// Ignore settings.json
|
||||
if file_name == state::SETTINGS_FILE_NAME {
|
||||
continue;
|
||||
}
|
||||
// Ignore caches folder
|
||||
if file_name == state::CACHES_FOLDER_NAME {
|
||||
continue;
|
||||
}
|
||||
// Ignore modrinth_logs folder
|
||||
if file_name == state::LAUNCHER_LOGS_FOLDER_NAME {
|
||||
continue;
|
||||
}
|
||||
|
||||
let new_path = new_config_dir.join(file_name);
|
||||
io::rename(entry_path, new_path).await?;
|
||||
|
||||
i += 1.0;
|
||||
emit_loading(&loading_bar, 90.0 * (i / total_entries), None)
|
||||
.await?;
|
||||
}
|
||||
// Delete entries that were from a different drive
|
||||
let deletable_entries_len = deletable_entries.len();
|
||||
if deletable_entries_len > 0 {
|
||||
tracing::trace!("Deleting old files");
|
||||
}
|
||||
for entry in deletable_entries {
|
||||
io::remove_dir_all(entry).await?;
|
||||
emit_loading(
|
||||
&loading_bar,
|
||||
10.0 * (1.0 / deletable_entries_len as f64),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Reset file watcher
|
||||
@@ -180,11 +217,30 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
|
||||
|
||||
emit_loading(&loading_bar, 10.0, None).await?;
|
||||
|
||||
// TODO: need to be able to safely error out of this function, reverting the changes
|
||||
tracing::info!(
|
||||
"Successfully switched config folder to: {}",
|
||||
new_config_dir.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Function to check if two paths are on different drives/roots
|
||||
fn is_different_drive(path1: &Path, path2: &Path) -> bool {
|
||||
let root1 = path1.components().next();
|
||||
let root2 = path2.components().next();
|
||||
root1 != root2
|
||||
}
|
||||
|
||||
pub async fn is_dir_writeable(new_config_dir: PathBuf) -> crate::Result<bool> {
|
||||
let temp_path = new_config_dir.join(".tmp");
|
||||
match fs::write(temp_path.clone(), "test").await {
|
||||
Ok(_) => {
|
||||
fs::remove_file(temp_path).await?;
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error writing to new config dir: {}", e);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
889
theseus/src/api/shared_profile.rs
Normal file
889
theseus/src/api/shared_profile.rs
Normal 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)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 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/";
|
||||
|
||||
@@ -229,6 +229,9 @@ pub enum CommandPayload {
|
||||
// run or install .mrpack
|
||||
path: PathBuf,
|
||||
},
|
||||
OpenSharedProfile {
|
||||
link: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
|
||||
@@ -148,7 +148,6 @@ pub fn get_jvm_arguments(
|
||||
parsed_arguments.push(arg);
|
||||
}
|
||||
}
|
||||
parsed_arguments.push("-Dorg.lwjgl.util.Debug=true".to_string());
|
||||
|
||||
Ok(parsed_arguments)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ pub async fn refresh_credentials(
|
||||
.as_error())
|
||||
}
|
||||
xsts_token::XSTSResponse::Success { token: xsts_token } => {
|
||||
let bearer_token =
|
||||
let (bearer_token, expires_in) =
|
||||
bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
@@ -76,8 +76,7 @@ pub async fn refresh_credentials(
|
||||
|
||||
credentials.access_token = bearer_token;
|
||||
credentials.refresh_token = oauth.refresh_token;
|
||||
credentials.expires =
|
||||
Utc::now() + Duration::seconds(oauth.expires_in);
|
||||
credentials.expires = Utc::now() + Duration::seconds(expires_in);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use daedalus as d;
|
||||
use daedalus::minecraft::{RuleAction, VersionInfo};
|
||||
use st::Profile;
|
||||
use std::collections::HashMap;
|
||||
use std::{process::Stdio, sync::Arc};
|
||||
use std::sync::Arc;
|
||||
use tokio::process::Command;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -177,7 +177,7 @@ pub async fn install_minecraft(
|
||||
|
||||
let state = State::get().await?;
|
||||
let instance_path =
|
||||
&io::canonicalize(&profile.get_profile_full_path().await?)?;
|
||||
&io::canonicalize(profile.get_profile_full_path().await?)?;
|
||||
let metadata = state.metadata.read().await;
|
||||
|
||||
let version_index = metadata
|
||||
@@ -511,9 +511,7 @@ pub async fn launch_minecraft(
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.current_dir(instance_path.clone())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
.current_dir(instance_path.clone());
|
||||
|
||||
// CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
use super::DirectoryInfo;
|
||||
use super::{Profile, ProfilePathId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use sysinfo::PidExt;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::ChildStderr;
|
||||
use tokio::process::ChildStdout;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::error;
|
||||
|
||||
use crate::event::emit::emit_process;
|
||||
use crate::event::ProcessPayloadType;
|
||||
@@ -201,7 +192,6 @@ impl ChildType {
|
||||
pub struct MinecraftChild {
|
||||
pub uuid: Uuid,
|
||||
pub profile_relative_path: ProfilePathId,
|
||||
pub output: Option<SharedOutput>,
|
||||
pub manager: Option<JoinHandle<crate::Result<i32>>>, // None when future has completed and been handled
|
||||
pub current_child: Arc<RwLock<ChildType>>,
|
||||
pub last_updated_playtime: DateTime<Utc>, // The last time we updated the playtime for the associated profile
|
||||
@@ -281,44 +271,9 @@ impl Children {
|
||||
censor_strings: HashMap<String, String>,
|
||||
) -> crate::Result<Arc<RwLock<MinecraftChild>>> {
|
||||
// Takes the first element of the commands vector and spawns it
|
||||
let mut child = mc_command.spawn().map_err(IOError::from)?;
|
||||
let mc_proc = mc_command.spawn().map_err(IOError::from)?;
|
||||
|
||||
// Create std watcher threads for stdout and stderr
|
||||
let log_path = DirectoryInfo::profile_logs_dir(&profile_relative_path)
|
||||
.await?
|
||||
.join("latest_stdout.log");
|
||||
let shared_output =
|
||||
SharedOutput::build(&log_path, censor_strings).await?;
|
||||
if let Some(child_stdout) = child.stdout.take() {
|
||||
let stdout_clone = shared_output.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = stdout_clone.read_stdout(child_stdout).await {
|
||||
error!("Stdout process died with error: {}", e);
|
||||
let _ = stdout_clone
|
||||
.push_line(format!(
|
||||
"Stdout process died with error: {}",
|
||||
e
|
||||
))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(child_stderr) = child.stderr.take() {
|
||||
let stderr_clone = shared_output.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = stderr_clone.read_stderr(child_stderr).await {
|
||||
error!("Stderr process died with error: {}", e);
|
||||
let _ = stderr_clone
|
||||
.push_line(format!(
|
||||
"Stderr process died with error: {}",
|
||||
e
|
||||
))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let child = ChildType::TokioChild(child);
|
||||
let child = ChildType::TokioChild(mc_proc);
|
||||
|
||||
// Slots child into manager
|
||||
let pid = child.id().ok_or_else(|| {
|
||||
@@ -358,7 +313,6 @@ impl Children {
|
||||
let mchild = MinecraftChild {
|
||||
uuid,
|
||||
profile_relative_path,
|
||||
output: Some(shared_output),
|
||||
current_child,
|
||||
manager,
|
||||
last_updated_playtime,
|
||||
@@ -449,7 +403,6 @@ impl Children {
|
||||
let mchild = MinecraftChild {
|
||||
uuid: cached_process.uuid,
|
||||
profile_relative_path: cached_process.profile_relative_path,
|
||||
output: None, // No output for cached/rescued processes
|
||||
current_child,
|
||||
manager,
|
||||
last_updated_playtime,
|
||||
@@ -758,117 +711,3 @@ impl Default for Children {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// SharedOutput, a wrapper around a String that can be read from and written to concurrently
|
||||
// Designed to be used with ChildStdout and ChildStderr in a tokio thread to have a simple String storage for the output of a child process
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SharedOutput {
|
||||
log_file: Arc<RwLock<File>>,
|
||||
censor_strings: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl SharedOutput {
|
||||
#[tracing::instrument(skip(censor_strings))]
|
||||
async fn build(
|
||||
log_file_path: &Path,
|
||||
censor_strings: HashMap<String, String>,
|
||||
) -> crate::Result<Self> {
|
||||
// create log_file_path parent if it doesn't exist
|
||||
let parent_folder = log_file_path.parent().ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Could not get parent folder of {:?}",
|
||||
log_file_path
|
||||
))
|
||||
})?;
|
||||
tokio::fs::create_dir_all(parent_folder)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, parent_folder))?;
|
||||
|
||||
Ok(SharedOutput {
|
||||
log_file: Arc::new(RwLock::new(
|
||||
File::create(log_file_path)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, log_file_path))?,
|
||||
)),
|
||||
censor_strings,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_stdout(
|
||||
&self,
|
||||
child_stdout: ChildStdout,
|
||||
) -> crate::Result<()> {
|
||||
let mut buf_reader = BufReader::new(child_stdout);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
while buf_reader
|
||||
.read_until(b'\n', &mut buf)
|
||||
.await
|
||||
.map_err(IOError::from)?
|
||||
> 0
|
||||
{
|
||||
let line = String::from_utf8_lossy(&buf).into_owned();
|
||||
let val_line = self.censor_log(line.clone());
|
||||
{
|
||||
let mut log_file = self.log_file.write().await;
|
||||
log_file
|
||||
.write_all(val_line.as_bytes())
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_stderr(
|
||||
&self,
|
||||
child_stderr: ChildStderr,
|
||||
) -> crate::Result<()> {
|
||||
let mut buf_reader = BufReader::new(child_stderr);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// TODO: these can be asbtracted into noe function
|
||||
while buf_reader
|
||||
.read_until(b'\n', &mut buf)
|
||||
.await
|
||||
.map_err(IOError::from)?
|
||||
> 0
|
||||
{
|
||||
let line = String::from_utf8_lossy(&buf).into_owned();
|
||||
let val_line = self.censor_log(line.clone());
|
||||
{
|
||||
let mut log_file = self.log_file.write().await;
|
||||
log_file
|
||||
.write_all(val_line.as_bytes())
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn push_line(&self, line: String) -> crate::Result<()> {
|
||||
let val_line = self.censor_log(line.clone());
|
||||
{
|
||||
let mut log_file = self.log_file.write().await;
|
||||
log_file
|
||||
.write_all(val_line.as_bytes())
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn censor_log(&self, mut val: String) -> String {
|
||||
for (find, replace) in &self.censor_strings {
|
||||
val = val.replace(find, replace);
|
||||
}
|
||||
|
||||
val
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ use super::{ProfilePathId, Settings};
|
||||
pub const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
pub const CACHES_FOLDER_NAME: &str = "caches";
|
||||
pub const LAUNCHER_LOGS_FOLDER_NAME: &str = "launcher_logs";
|
||||
pub const PROFILES_FOLDER_NAME: &str = "profiles";
|
||||
pub const METADATA_FOLDER_NAME: &str = "meta";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DirectoryInfo {
|
||||
@@ -75,7 +77,7 @@ impl DirectoryInfo {
|
||||
/// Get the Minecraft instance metadata directory
|
||||
#[inline]
|
||||
pub async fn metadata_dir(&self) -> PathBuf {
|
||||
self.config_dir.read().await.join("meta")
|
||||
self.config_dir.read().await.join(METADATA_FOLDER_NAME)
|
||||
}
|
||||
|
||||
/// Get the Minecraft java versions metadata directory
|
||||
@@ -153,7 +155,7 @@ impl DirectoryInfo {
|
||||
/// Get the profiles directory for created profiles
|
||||
#[inline]
|
||||
pub async fn profiles_dir(&self) -> PathBuf {
|
||||
self.config_dir.read().await.join("profiles")
|
||||
self.config_dir.read().await.join(PROFILES_FOLDER_NAME)
|
||||
}
|
||||
|
||||
/// Gets the logs dir for a given profile
|
||||
|
||||
@@ -247,13 +247,16 @@ impl State {
|
||||
tokio::task::spawn(async {
|
||||
if let Ok(state) = crate::State::get().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 res2 = Tags::update();
|
||||
let res3 = Metadata::update();
|
||||
let res4 = Profiles::update_projects();
|
||||
let res5 = Settings::update_java();
|
||||
let res6 = CredentialsStore::update_creds();
|
||||
let res7 = Settings::update_default_user();
|
||||
let res6 = Settings::update_default_user();
|
||||
let res7 = Profiles::update_shared_projects();
|
||||
|
||||
let _ = join!(res1, res2, res3, res4, res5, res6, res7);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ pub struct ModrinthAuthFlow {
|
||||
impl ModrinthAuthFlow {
|
||||
pub async fn new(provider: &str) -> crate::Result<Self> {
|
||||
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?;
|
||||
Ok(Self { socket })
|
||||
@@ -209,7 +209,7 @@ async fn get_result_from_res(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct Session {
|
||||
session: String,
|
||||
}
|
||||
@@ -351,6 +351,7 @@ pub async fn refresh_credentials(
|
||||
}
|
||||
}
|
||||
|
||||
credentials_store.save().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ use crate::data::DirectoryInfo;
|
||||
use crate::event::emit::{emit_profile, emit_warning};
|
||||
use crate::event::ProfilePayloadType;
|
||||
use crate::prelude::JavaVersion;
|
||||
use crate::shared_profile::SharedModpackFileUpdate;
|
||||
use crate::state::projects::Project;
|
||||
use crate::state::{ModrinthVersion, ProjectMetadata, ProjectType};
|
||||
use crate::util::fetch::{
|
||||
fetch, fetch_json, write, write_cached_icon, IoSemaphore,
|
||||
};
|
||||
use crate::util::io::{self, IOError};
|
||||
use crate::State;
|
||||
use crate::{shared_profile, State};
|
||||
use chrono::{DateTime, Utc};
|
||||
use daedalus::get_hash;
|
||||
use daedalus::modded::LoaderVersion;
|
||||
@@ -164,7 +165,7 @@ impl ProjectPathId {
|
||||
|
||||
pub async fn get_full_path(
|
||||
&self,
|
||||
profile: ProfilePathId,
|
||||
profile: &ProfilePathId,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let profile_dir = profile.get_full_path().await?;
|
||||
Ok(profile_dir.join(&self.0))
|
||||
@@ -209,7 +210,14 @@ pub struct Profile {
|
||||
pub hooks: Option<Hooks>,
|
||||
pub projects: HashMap<ProjectPathId, Project>,
|
||||
#[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)]
|
||||
@@ -244,12 +252,19 @@ pub struct ProfileMetadata {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct LinkedData {
|
||||
pub project_id: Option<String>,
|
||||
pub version_id: Option<String>,
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LinkedData {
|
||||
ModrinthModpack {
|
||||
project_id: Option<String>,
|
||||
version_id: Option<String>,
|
||||
#[serde(default = "default_locked")]
|
||||
locked: Option<bool>,
|
||||
},
|
||||
|
||||
#[serde(default = "default_locked")]
|
||||
pub locked: Option<bool>,
|
||||
SharedProfile {
|
||||
profile_id: String,
|
||||
is_owner: bool,
|
||||
},
|
||||
}
|
||||
|
||||
// Called if linked_data is present but locked is not
|
||||
@@ -344,7 +359,7 @@ impl Profile {
|
||||
resolution: None,
|
||||
fullscreen: None,
|
||||
hooks: None,
|
||||
modrinth_update_version: None,
|
||||
sync_update_version: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -424,6 +439,10 @@ impl Profile {
|
||||
&creds,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Check existing shared profiles for updates
|
||||
tokio::task::spawn(Profiles::update_shared_projects());
|
||||
|
||||
drop(creds);
|
||||
|
||||
let mut new_profiles = state.profiles.write().await;
|
||||
@@ -580,6 +599,49 @@ impl Profile {
|
||||
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))]
|
||||
#[theseus_macros::debug_pin]
|
||||
pub async fn add_project_bytes(
|
||||
@@ -872,19 +934,23 @@ impl Profiles {
|
||||
pub async fn update_modrinth_versions() {
|
||||
let res = async {
|
||||
let state = State::get().await?;
|
||||
|
||||
// First, we'll fetch updates for all Modrinth modpacks
|
||||
// Temporarily store all profiles that have modrinth linked data
|
||||
let mut modrinth_updatables: Vec<(ProfilePathId, String)> =
|
||||
Vec::new();
|
||||
{
|
||||
let profiles = state.profiles.read().await;
|
||||
for (profile_path, profile) in profiles.0.iter() {
|
||||
if let Some(linked_data) = &profile.metadata.linked_data {
|
||||
if let Some(linked_project) = &linked_data.project_id {
|
||||
modrinth_updatables.push((
|
||||
profile_path.clone(),
|
||||
linked_project.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(LinkedData::ModrinthModpack {
|
||||
project_id: Some(ref linked_project),
|
||||
..
|
||||
}) = &profile.metadata.linked_data
|
||||
{
|
||||
modrinth_updatables.push((
|
||||
profile_path.clone(),
|
||||
linked_project.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -922,10 +988,12 @@ impl Profiles {
|
||||
.contains(&loader.as_api_str().to_string())
|
||||
});
|
||||
if let Some(recent_version) = recent_version {
|
||||
profile.modrinth_update_version =
|
||||
Some(recent_version.id.clone());
|
||||
profile.sync_update_version =
|
||||
Some(ProfileUpdateData::ModrinthModpack(
|
||||
recent_version.id.clone(),
|
||||
));
|
||||
} else {
|
||||
profile.modrinth_update_version = None;
|
||||
profile.sync_update_version = None;
|
||||
}
|
||||
}
|
||||
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))]
|
||||
#[theseus_macros::debug_pin]
|
||||
pub async fn insert(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// 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;
|
||||
use std::{io::Write, path::Path};
|
||||
|
||||
use tauri::async_runtime::spawn_blocking;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IOError {
|
||||
@@ -113,15 +116,40 @@ pub async fn write(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
data: impl AsRef<[u8]>,
|
||||
) -> Result<(), IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::write(path, data)
|
||||
.await
|
||||
.map_err(|e| IOError::IOPathError {
|
||||
let path = path.as_ref().to_owned();
|
||||
let data = data.as_ref().to_owned();
|
||||
spawn_blocking(move || {
|
||||
let cloned_path = path.clone();
|
||||
sync_write(data, path).map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
path: cloned_path.to_string_lossy().to_string(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::Other, "background task failed")
|
||||
})??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_write(
|
||||
data: impl AsRef<[u8]>,
|
||||
path: impl AsRef<Path>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let mut tempfile =
|
||||
NamedTempFile::new_in(path.as_ref().parent().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"could not get parent directory for temporary file",
|
||||
)
|
||||
})?)?;
|
||||
tempfile.write_all(data.as_ref())?;
|
||||
let tmp_path = tempfile.into_temp_path();
|
||||
let path = path.as_ref();
|
||||
tmp_path.persist(path)?;
|
||||
std::io::Result::Ok(())
|
||||
}
|
||||
// rename
|
||||
pub async fn rename(
|
||||
from: impl AsRef<std::path::Path>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus_cli"
|
||||
version = "0.6.1"
|
||||
version = "0.6.3"
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
|
||||
@@ -175,12 +175,13 @@ impl ProfileInit {
|
||||
.ok_or_else(|| eyre::eyre!("Modloader {loader} unsupported for Minecraft version {game_version}"))?
|
||||
.loaders;
|
||||
|
||||
let loader_version =
|
||||
loaders.iter().cloned().find(filter).ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"Invalid version {version} for modloader {loader}"
|
||||
)
|
||||
})?;
|
||||
let loader_version = loaders
|
||||
.iter()
|
||||
.find(|&x| filter(x))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
eyre::eyre!("Invalid version {version} for modloader {loader}")
|
||||
})?;
|
||||
|
||||
Some((loader_version, loader))
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "theseus_gui",
|
||||
"private": true,
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
version = "0.6.1"
|
||||
version = "0.6.3"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
|
||||
@@ -23,7 +23,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
logs_delete_logs,
|
||||
logs_delete_logs_by_filename,
|
||||
logs_get_latest_log_cursor,
|
||||
logs_get_std_log_cursor,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -91,12 +90,3 @@ pub async fn logs_get_latest_log_cursor(
|
||||
) -> Result<LatestLogCursor> {
|
||||
Ok(logs::get_latest_log_cursor(profile_path, cursor).await?)
|
||||
}
|
||||
|
||||
/// Get live stdout log from a cursor
|
||||
#[tauri::command]
|
||||
pub async fn logs_get_std_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
cursor: u64, // 0 to start at beginning of file
|
||||
) -> Result<LatestLogCursor> {
|
||||
Ok(logs::get_std_log_cursor(profile_path, cursor).await?)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod pack;
|
||||
pub mod process;
|
||||
pub mod profile;
|
||||
pub mod profile_create;
|
||||
pub mod profile_share;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
pub mod utils;
|
||||
|
||||
97
theseus_gui/src-tauri/src/api/profile_share.rs
Normal file
97
theseus_gui/src-tauri/src/api/profile_share.rs
Normal 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(())
|
||||
}
|
||||
@@ -8,7 +8,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
settings_get,
|
||||
settings_set,
|
||||
settings_change_config_dir
|
||||
settings_change_config_dir,
|
||||
settings_is_dir_writeable
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -37,3 +38,11 @@ pub async fn settings_change_config_dir(new_config_dir: PathBuf) -> Result<()> {
|
||||
settings::set_config_dir(new_config_dir).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn settings_is_dir_writeable(
|
||||
new_config_dir: PathBuf,
|
||||
) -> Result<bool> {
|
||||
let res = settings::is_dir_writeable(new_config_dir).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
get_opening_command,
|
||||
await_sync,
|
||||
is_offline,
|
||||
refresh_offline
|
||||
refresh_offline,
|
||||
test_command,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -159,6 +160,11 @@ pub async fn get_opening_command() -> Result<Option<CommandPayload>> {
|
||||
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)
|
||||
// We hijack the deep link library (which also contains functionality for instance-checking)
|
||||
pub async fn handle_command(command: String) -> Result<()> {
|
||||
|
||||
@@ -139,6 +139,7 @@ fn main() {
|
||||
.plugin(api::process::init())
|
||||
.plugin(api::profile::init())
|
||||
.plugin(api::profile_create::init())
|
||||
.plugin(api::profile_share::init())
|
||||
.plugin(api::settings::init())
|
||||
.plugin(api::tags::init())
|
||||
.plugin(api::utils::init())
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Modrinth App",
|
||||
"version": "0.6.1"
|
||||
"version": "0.6.3"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
@@ -83,7 +83,7 @@
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src https://modrinth.com https://*.modrinth.com https://mixpanel.com https://*.mixpanel.com https://*.cloudflare.com https://api.mclo.gs; font-src https://cdn-raw.modrinth.com/fonts/inter/; img-src tauri: https: data: blob: 'unsafe-inline' asset: https://asset.localhost; script-src https://*.cloudflare.com 'self'; frame-src https://*.cloudflare.com https://www.youtube.com 'self'; style-src unsafe-inline 'self'"
|
||||
"csp": "default-src 'self'; connect-src https://modrinth.com https://*.modrinth.com https://mixpanel.com https://*.mixpanel.com https://*.cloudflare.com https://api.mclo.gs; font-src https://cdn-raw.modrinth.com/fonts/inter/; img-src tauri: https: data: blob: 'unsafe-inline' asset: https://asset.localhost; script-src https://*.cloudflare.com 'self'; frame-src https://*.cloudflare.com https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'; style-src unsafe-inline 'self'"
|
||||
},
|
||||
"updater": {
|
||||
"active": true,
|
||||
|
||||
@@ -40,12 +40,14 @@ import { TauriEvent } from '@tauri-apps/api/event'
|
||||
import { await_sync, check_safe_loading_bars_complete } from './helpers/state'
|
||||
import { confirm } from '@tauri-apps/api/dialog'
|
||||
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
|
||||
import AcceptSharedProfileModal from '@/components/ui/AcceptSharedProfileModal.vue'
|
||||
import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue'
|
||||
import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue'
|
||||
import { install_from_file } from './helpers/pack'
|
||||
|
||||
const themeStore = useTheming()
|
||||
const urlModal = ref(null)
|
||||
const sharedProfileConfirmModal = ref(null)
|
||||
const isLoading = ref(true)
|
||||
|
||||
const videoPlaying = ref(false)
|
||||
@@ -237,6 +239,9 @@ command_listener(async (e) => {
|
||||
source: 'CreationModalFileDrop',
|
||||
})
|
||||
}
|
||||
} else if (e.event === 'OpenSharedProfile') {
|
||||
// Install a shared profile
|
||||
sharedProfileConfirmModal.value.show(e)
|
||||
} else {
|
||||
// Other commands are URL-based (deep linking)
|
||||
urlModal.value.show(e)
|
||||
@@ -388,6 +393,7 @@ command_listener(async (e) => {
|
||||
</div>
|
||||
</div>
|
||||
<URLConfirmModal ref="urlModal" />
|
||||
<AcceptSharedProfileModal ref="sharedProfileConfirmModal" />
|
||||
<Notifications ref="notificationsWrapper" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ const filteredResults = computed(() => {
|
||||
})
|
||||
} else if (filters.value === 'Downloaded modpacks') {
|
||||
instances = instances.filter((instance) => {
|
||||
return instance.metadata?.linked_data
|
||||
return instance.metadata?.linked_data?.modrinth_modpack
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ const handleOptionsClick = async (args) => {
|
||||
break
|
||||
case 'install': {
|
||||
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'
|
||||
)
|
||||
|
||||
|
||||
79
theseus_gui/src/components/ui/AcceptSharedProfileModal.vue
Normal file
79
theseus_gui/src/components/ui/AcceptSharedProfileModal.vue
Normal 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>
|
||||
@@ -11,7 +11,7 @@
|
||||
:size="mode === 'expanded' ? 'xs' : 'sm'"
|
||||
:src="
|
||||
selectedAccount
|
||||
? `https://crafatar.com/avatars/${selectedAccount.id}?size=128&overlay`
|
||||
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
|
||||
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
"
|
||||
/>
|
||||
@@ -24,10 +24,7 @@
|
||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
||||
>
|
||||
<div v-if="selectedAccount" class="selected account">
|
||||
<Avatar
|
||||
size="xs"
|
||||
:src="`https://crafatar.com/avatars/${selectedAccount.id}?size=128&overlay`"
|
||||
/>
|
||||
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
|
||||
<div>
|
||||
<h4>{{ selectedAccount.username }}</h4>
|
||||
<p>Selected</p>
|
||||
@@ -45,10 +42,7 @@
|
||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
||||
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
|
||||
<Button class="option account" @click="setAccount(account)">
|
||||
<Avatar
|
||||
:src="`https://crafatar.com/avatars/${selectedAccount.id}?size=128&overlay`"
|
||||
class="icon"
|
||||
/>
|
||||
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
|
||||
<p>{{ account.username }}</p>
|
||||
</Button>
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
|
||||
@@ -62,7 +56,7 @@
|
||||
</Button>
|
||||
</Card>
|
||||
</transition>
|
||||
<Modal ref="loginModal" class="modal" header="Signing in">
|
||||
<Modal ref="loginModal" class="modal" header="Signing in" :noblur="!themeStore.advancedRendering">
|
||||
<div class="modal-body">
|
||||
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
|
||||
<div class="modal-text">
|
||||
@@ -120,6 +114,7 @@ import {
|
||||
} from '@/helpers/auth'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { handleError } from '@/store/state.js'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
import { mixpanel_track } from '@/helpers/mixpanel'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
@@ -136,6 +131,7 @@ const emit = defineEmits(['change'])
|
||||
|
||||
const loginCode = ref(null)
|
||||
|
||||
const themeStore = useTheming()
|
||||
const settings = ref({})
|
||||
const accounts = ref([])
|
||||
const loginUrl = ref('')
|
||||
|
||||
@@ -292,9 +292,11 @@ const exportPack = async () => {
|
||||
.textarea-wrapper {
|
||||
// margin-top: 1rem;
|
||||
height: 12rem;
|
||||
|
||||
textarea {
|
||||
max-height: 12rem;
|
||||
}
|
||||
|
||||
.preview {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ const install = async (e) => {
|
||||
e?.stopPropagation()
|
||||
modLoading.value = true
|
||||
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'
|
||||
)
|
||||
|
||||
@@ -82,7 +82,9 @@ const install = async (e) => {
|
||||
packs.length === 0 ||
|
||||
!packs
|
||||
.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
|
||||
await pack_install(
|
||||
|
||||
@@ -247,7 +247,9 @@ const check_valid = computed(() => {
|
||||
</Button>
|
||||
<div
|
||||
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.'
|
||||
: ''
|
||||
"
|
||||
@@ -265,7 +267,8 @@ const check_valid = computed(() => {
|
||||
? 'Installing...'
|
||||
: profile.installedMod
|
||||
? 'Installed'
|
||||
: profile.metadata.linked_data && profile.metadata.linked_data.locked
|
||||
: profile.metadata.linked_data?.modrinth_modpack.locked ||
|
||||
profile.metadata.linked_data?.shared_profile
|
||||
? 'Paired'
|
||||
: 'Install'
|
||||
}}
|
||||
|
||||
@@ -28,7 +28,9 @@ const filteredVersions = computed(() => {
|
||||
})
|
||||
|
||||
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 inProgress = ref(false)
|
||||
|
||||
@@ -49,7 +51,7 @@ const switchVersion = async (versionId) => {
|
||||
:noblur="!themeStore.advancedRendering"
|
||||
>
|
||||
<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-row with-columns table-head">
|
||||
<div class="table-cell table-text download-cell" />
|
||||
|
||||
@@ -73,7 +73,7 @@ const install = async (e) => {
|
||||
e?.stopPropagation()
|
||||
installing.value = true
|
||||
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'
|
||||
)
|
||||
|
||||
@@ -84,7 +84,7 @@ const install = async (e) => {
|
||||
packs.length === 0 ||
|
||||
!packs
|
||||
.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
|
||||
await pack_install(
|
||||
|
||||
@@ -135,7 +135,7 @@ const installed = ref(props.installed)
|
||||
async function install() {
|
||||
installing.value = true
|
||||
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'
|
||||
)
|
||||
let queuedVersionData
|
||||
@@ -156,7 +156,7 @@ async function install() {
|
||||
packs.length === 0 ||
|
||||
!packs
|
||||
.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(
|
||||
props.project.project_id,
|
||||
|
||||
@@ -20,20 +20,24 @@ defineExpose({
|
||||
async show(event) {
|
||||
if (event.event === 'InstallVersion') {
|
||||
version.value = await useFetch(
|
||||
`https://api.modrinth.com/v2/version/${encodeURIComponent(event.id)}`,
|
||||
`https://staging-api.modrinth.com/v2/version/${encodeURIComponent(event.id)}`,
|
||||
'version'
|
||||
)
|
||||
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'
|
||||
)
|
||||
} else {
|
||||
project.value = await useFetch(
|
||||
`https://api.modrinth.com/v2/project/${encodeURIComponent(event.id)}`,
|
||||
`https://staging-api.modrinth.com/v2/project/${encodeURIComponent(event.id)}`,
|
||||
'project'
|
||||
)
|
||||
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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { Button, LogInIcon, Modal, ClipboardCopyIcon, GlobeIcon, Card } from 'omorphia'
|
||||
import { authenticate_await_completion, authenticate_begin_flow } from '@/helpers/auth.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
import mixpanel from 'mixpanel-browser'
|
||||
import { get, set } from '@/helpers/settings.js'
|
||||
import { ref } from 'vue'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
|
||||
const themeStore = useTheming()
|
||||
const loginUrl = ref(null)
|
||||
const loginModal = ref()
|
||||
const loginCode = ref(null)
|
||||
@@ -94,7 +96,7 @@ const clipboardWrite = async (a) => {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Modal ref="loginModal" header="Signing in">
|
||||
<Modal ref="loginModal" header="Signing in" :noblur="!themeStore.advancedRendering">
|
||||
<div class="modal-body">
|
||||
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
|
||||
<div class="modal-text">
|
||||
|
||||
@@ -54,7 +54,3 @@ export async function delete_logs(profilePath) {
|
||||
export async function get_latest_log_cursor(profilePath, cursor) {
|
||||
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
|
||||
}
|
||||
// For std log (from modrinth app written latest_stdout.log, contains stdout and stderr)
|
||||
export async function get_std_log_cursor(profilePath, cursor) {
|
||||
return await invoke('plugin:logs|logs_get_std_log_cursor', { profilePath, cursor })
|
||||
}
|
||||
|
||||
@@ -43,3 +43,7 @@ export async function set(settings) {
|
||||
export async function change_config_dir(newConfigDir) {
|
||||
return await invoke('plugin:settings|settings_change_config_dir', { newConfigDir })
|
||||
}
|
||||
|
||||
export async function is_dir_writeable(newConfigDir) {
|
||||
return await invoke('plugin:settings|settings_is_dir_writeable', { newConfigDir })
|
||||
}
|
||||
|
||||
58
theseus_gui/src/helpers/shared_profiles.js
Normal file
58
theseus_gui/src/helpers/shared_profiles.js
Normal 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 })
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export const installVersionDependencies = async (profile, version) => {
|
||||
)
|
||||
continue
|
||||
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'
|
||||
)
|
||||
const latest = depVersions.find(
|
||||
|
||||
@@ -29,10 +29,15 @@ const raw_invoke = async (plugin, fn, args) => {
|
||||
return await invoke('plugin:' + plugin + '|' + fn, args)
|
||||
}
|
||||
}
|
||||
const test_command = async (command) => {
|
||||
return await raw_invoke('utils', 'test_command', { command })
|
||||
}
|
||||
|
||||
isDev()
|
||||
.then((dev) => {
|
||||
if (dev) {
|
||||
window.raw_invoke = raw_invoke
|
||||
window.test_command = test_command
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -149,7 +149,7 @@ if (route.query.ai) {
|
||||
}
|
||||
|
||||
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}`]
|
||||
if (query.value.length > 0) {
|
||||
|
||||
@@ -31,8 +31,10 @@ const getInstances = async () => {
|
||||
|
||||
let filters = []
|
||||
for (const instance of recentInstances.value) {
|
||||
if (instance.metadata.linked_data && instance.metadata.linked_data.project_id) {
|
||||
filters.push(`NOT"project_id"="${instance.metadata.linked_data.project_id}"`)
|
||||
if (instance.metadata.linked_data?.modrinth_modpack?.project_id) {
|
||||
filters.push(
|
||||
`NOT"project_id"="${instance.metadata.linked_data?.modrinth_modpack?.project_id}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
filter.value = filters.join(' AND ')
|
||||
@@ -40,7 +42,7 @@ const getInstances = async () => {
|
||||
|
||||
const getFeaturedModpacks = async () => {
|
||||
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',
|
||||
offline.value
|
||||
)
|
||||
@@ -52,7 +54,7 @@ const getFeaturedModpacks = async () => {
|
||||
}
|
||||
const getFeaturedMods = async () => {
|
||||
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',
|
||||
offline.value
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
UpdatedIcon,
|
||||
} from 'omorphia'
|
||||
import { handleError, useTheming } from '@/store/state'
|
||||
import { change_config_dir, get, set } from '@/helpers/settings'
|
||||
import { is_dir_writeable, change_config_dir, get, set } from '@/helpers/settings'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { get as getCreds, logout } from '@/helpers/mr_auth.js'
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
@@ -22,6 +22,7 @@ import ModrinthLoginScreen from '@/components/ui/tutorial/ModrinthLoginScreen.vu
|
||||
import { mixpanel_opt_out_tracking, mixpanel_opt_in_tracking } from '@/helpers/mixpanel'
|
||||
import { open } from '@tauri-apps/api/dialog'
|
||||
import { getOS } from '@/helpers/utils.js'
|
||||
import { version } from '../../package.json'
|
||||
|
||||
const pageOptions = ['Home', 'Library']
|
||||
|
||||
@@ -39,6 +40,8 @@ const accessSettings = async () => {
|
||||
return settings
|
||||
}
|
||||
|
||||
// const launcherVersion = await get_launcher_version().catch(handleError)
|
||||
|
||||
const fetchSettings = await accessSettings().catch(handleError)
|
||||
|
||||
const settings = ref(fetchSettings)
|
||||
@@ -116,7 +119,18 @@ async function signInAfter() {
|
||||
}
|
||||
|
||||
async function findLauncherDir() {
|
||||
const newDir = await open({ multiple: false, directory: true })
|
||||
const newDir = await open({
|
||||
multiple: false,
|
||||
directory: true,
|
||||
title: 'Select a new app directory',
|
||||
})
|
||||
|
||||
const writeable = await is_dir_writeable(newDir)
|
||||
|
||||
if (!writeable) {
|
||||
handleError('The selected directory does not have proper permissions for write access.')
|
||||
return
|
||||
}
|
||||
|
||||
if (newDir) {
|
||||
settingsDir.value = newDir
|
||||
@@ -125,7 +139,7 @@ async function findLauncherDir() {
|
||||
}
|
||||
|
||||
async function refreshDir() {
|
||||
await change_config_dir(settingsDir.value)
|
||||
await change_config_dir(settingsDir.value).catch(handleError)
|
||||
settings.value = await accessSettings().catch(handleError)
|
||||
settingsDir.value = settings.value.loaded_config_dir
|
||||
}
|
||||
@@ -154,9 +168,13 @@ async function refreshDir() {
|
||||
</span>
|
||||
<span v-else> Sign in to your Modrinth account. </span>
|
||||
</label>
|
||||
<button v-if="credentials" class="btn" @click="logOut"><LogOutIcon /> Sign out</button>
|
||||
<button v-if="credentials" class="btn" @click="logOut">
|
||||
<LogOutIcon />
|
||||
Sign out
|
||||
</button>
|
||||
<button v-else class="btn" @click="$refs.loginScreenModal.show()">
|
||||
<LogInIcon /> Sign in
|
||||
<LogInIcon />
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
<label for="theme">
|
||||
@@ -517,6 +535,19 @@ async function refreshDir() {
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">About</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<span class="label__title">App version</span>
|
||||
<span class="label__description">Theseus v{{ version }} </span>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -209,9 +209,9 @@ const checkProcess = async () => {
|
||||
|
||||
// Get information on associated modrinth versions, if any
|
||||
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(
|
||||
`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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ import {
|
||||
delete_logs_by_filename,
|
||||
get_logs,
|
||||
get_output_by_filename,
|
||||
get_std_log_cursor,
|
||||
get_latest_log_cursor,
|
||||
} from '@/helpers/logs.js'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -139,7 +139,7 @@ const props = defineProps({
|
||||
|
||||
const currentLiveLog = ref(null)
|
||||
const currentLiveLogCursor = ref(0)
|
||||
const emptyText = ['No live game detected.', 'Start your game to proceed']
|
||||
const emptyText = ['No live game detected.', 'Start your game to proceed.']
|
||||
|
||||
const logs = ref([])
|
||||
await setLogs()
|
||||
@@ -223,7 +223,7 @@ async function getLiveStdLog() {
|
||||
if (uuids.length === 0) {
|
||||
returnValue = emptyText.join('\n')
|
||||
} else {
|
||||
const logCursor = await get_std_log_cursor(
|
||||
const logCursor = await get_latest_log_cursor(
|
||||
props.instance.path,
|
||||
currentLiveLogCursor.value
|
||||
).catch(handleError)
|
||||
@@ -243,31 +243,15 @@ async function getLogs() {
|
||||
return (await get_logs(props.instance.path, true).catch(handleError))
|
||||
.reverse()
|
||||
.filter(
|
||||
// filter out latest_stdout.log or anything without .log in it
|
||||
(log) =>
|
||||
log.filename !== 'latest_stdout.log' &&
|
||||
log.filename !== 'latest_stdout' &&
|
||||
log.stdout !== ''
|
||||
log.stdout !== '' &&
|
||||
log.filename.includes('.log')
|
||||
)
|
||||
.map((log) => {
|
||||
if (log.filename == 'latest.log') {
|
||||
log.name = 'Latest Log'
|
||||
} else {
|
||||
let filename = log.filename.split('.')[0]
|
||||
let day = dayjs(filename.slice(0, 10))
|
||||
if (day.isValid()) {
|
||||
if (day.isToday()) {
|
||||
log.name = 'Today'
|
||||
} else if (day.isYesterday()) {
|
||||
log.name = 'Yesterday'
|
||||
} else {
|
||||
log.name = day.format('MMMM D, YYYY')
|
||||
}
|
||||
// Displays as "Today-1", "Today-2", etc, matching minecraft log naming but with the date
|
||||
log.name = log.name + filename.slice(10)
|
||||
} else {
|
||||
log.name = filename
|
||||
}
|
||||
}
|
||||
log.name = log.filename || 'Unknown'
|
||||
log.stdout = 'Loading...'
|
||||
return log
|
||||
})
|
||||
|
||||
@@ -359,7 +359,7 @@
|
||||
/>
|
||||
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
|
||||
<ModpackVersionModal
|
||||
v-if="instance.metadata.linked_data"
|
||||
v-if="instance.metadata.linked_data?.modrinth_modpack"
|
||||
ref="modpackVersionModal"
|
||||
:instance="instance"
|
||||
:versions="props.versions"
|
||||
@@ -443,11 +443,18 @@ const projects = ref([])
|
||||
const selectionMap = ref(new Map())
|
||||
const showingOptions = ref(false)
|
||||
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(() => {
|
||||
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)
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card v-if="instance.metadata.linked_data">
|
||||
<Card v-if="instance.metadata.linked_data?.modrinth_modpack">
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Modpack</span>
|
||||
@@ -413,8 +413,10 @@
|
||||
<XIcon /> Unpair
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="props.instance.metadata.linked_data.project_id" class="adjacent-input">
|
||||
<div
|
||||
v-if="props.instance.metadata.linked_data?.modrinth_modpack?.project_id"
|
||||
class="adjacent-input"
|
||||
>
|
||||
<label for="change-modpack-version">
|
||||
<span class="label__title">Change modpack version</span>
|
||||
<span class="label__description">
|
||||
@@ -445,6 +447,96 @@
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
<div class="label">
|
||||
<h3>
|
||||
@@ -502,7 +594,7 @@
|
||||
</div>
|
||||
</Card>
|
||||
<ModpackVersionModal
|
||||
v-if="instance.metadata.linked_data"
|
||||
v-if="instance.metadata.linked_data?.modrinth_modpack"
|
||||
ref="modpackVersionModal"
|
||||
:instance="instance"
|
||||
:versions="props.versions"
|
||||
@@ -527,6 +619,7 @@ import {
|
||||
HammerIcon,
|
||||
ModalConfirm,
|
||||
DownloadIcon,
|
||||
GlobeIcon,
|
||||
ClipboardCopyIcon,
|
||||
Button,
|
||||
Toggle,
|
||||
@@ -545,6 +638,13 @@ import {
|
||||
remove,
|
||||
update_repair_modrinth,
|
||||
} 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 { get_max_memory } from '@/helpers/jre.js'
|
||||
import { get } from '@/helpers/settings.js'
|
||||
@@ -659,12 +759,22 @@ const unlinkModpack = ref(false)
|
||||
|
||||
const inProgress = ref(false)
|
||||
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(() => {
|
||||
if (!installedVersion.value) return null
|
||||
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(
|
||||
[
|
||||
title,
|
||||
@@ -794,13 +904,13 @@ async function unpairProfile() {
|
||||
|
||||
async function unlockProfile() {
|
||||
const editProfile = props.instance
|
||||
editProfile.metadata.linked_data.locked = false
|
||||
editProfile.metadata.linked_data.modrinth_modpack.locked = false
|
||||
await edit(props.instance.path, editProfile)
|
||||
modalConfirmUnlock.value.hide()
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -932,6 +1042,23 @@ async function saveGvLoaderEdits() {
|
||||
editing.value = false
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -1012,4 +1139,29 @@ async function saveGvLoaderEdits() {
|
||||
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>
|
||||
|
||||
@@ -314,10 +314,13 @@ async function fetchProjectData() {
|
||||
categories.value,
|
||||
instance.value,
|
||||
] = await Promise.all([
|
||||
useFetch(`https://api.modrinth.com/v2/project/${route.params.id}`, 'project'),
|
||||
useFetch(`https://api.modrinth.com/v2/project/${route.params.id}/version`, 'project'),
|
||||
useFetch(`https://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}`, 'project'),
|
||||
useFetch(`https://staging-api.modrinth.com/v2/project/${route.params.id}/version`, 'project'),
|
||||
useFetch(`https://staging-api.modrinth.com/v2/project/${route.params.id}/members`, 'project'),
|
||||
useFetch(
|
||||
`https://staging-api.modrinth.com/v2/project/${route.params.id}/dependencies`,
|
||||
'project'
|
||||
),
|
||||
get_categories().catch(handleError),
|
||||
route.query.i ? getInstance(route.query.i, false).catch(handleError) : Promise.resolve(),
|
||||
])
|
||||
@@ -391,7 +394,7 @@ async function install(version) {
|
||||
packs.length === 0 ||
|
||||
!packs
|
||||
.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(
|
||||
data.value.id,
|
||||
|
||||
@@ -283,6 +283,7 @@ watch([filterVersions, filterLoader, filterGameVersions], () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
text-wrap: wrap;
|
||||
|
||||
.version-badge {
|
||||
display: flex;
|
||||
|
||||
@@ -2,10 +2,9 @@ import { defineStore } from 'pinia'
|
||||
|
||||
export const useTheming = defineStore('themeStore', {
|
||||
state: () => ({
|
||||
themeOptions: ['dark'],
|
||||
themeOptions: ['dark', 'light', 'oled'],
|
||||
advancedRendering: true,
|
||||
selectedTheme: 'dark',
|
||||
darkTheme: true,
|
||||
}),
|
||||
actions: {
|
||||
setThemeState(newTheme) {
|
||||
@@ -15,8 +14,9 @@ export const useTheming = defineStore('themeStore', {
|
||||
this.setThemeClass()
|
||||
},
|
||||
setThemeClass() {
|
||||
document.getElementsByTagName('html')[0].classList.remove('dark-mode')
|
||||
document.getElementsByTagName('html')[0].classList.remove('light-mode')
|
||||
for (const theme of this.themeOptions) {
|
||||
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
|
||||
}
|
||||
document.getElementsByTagName('html')[0].classList.add(`${this.selectedTheme}-mode`)
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user