Compare commits

..

7 Commits

Author SHA1 Message Date
Wyatt Verchere
6d9d403e7b Hydra local (#594)
* initial commit

* merge fixes

* added sanitizing

* linter

* Improve sign in UI

* simple simple!

* bump version

---------

Co-authored-by: CodexAdrian <83074853+CodexAdrian@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
2023-08-17 20:26:21 -04:00
Adrian O.V
49bfb0637f Community requested enhancements (#556)
* Make images lazy and fix #198

* Fix console spam

* Fix bug with bad pagination impl

* Fixes #232

* Finalize more bug fixes

* run lint

* Improve minecraft sign in, improve onboarding

* Linter

* Added back button

* Implement #530

* run linter

* Address changes

* Bump version + run fmt

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
2023-08-14 14:02:22 -07:00
Wyatt Verchere
d6ee1ff25a Beta bugs (#562)
* fixed bugs

* added logging for atlauncher

* draft: improving imports time

* more improvements

* more

* prettier, etc

* small changes

* emma suggested change

* rev

* removed atlauncher debug
2023-08-14 13:23:42 -07:00
chaos
a1a5b8ed9c Fix z-index and add scrolling for AccountsCard (#585)
* Fix z-index and add scrolling for AccountsCard

* Run Prettier on code.
2023-08-14 13:18:37 -07:00
Geometrically
5f0d44a881 more bug fixes (#485)
* more bug fixes

* remove console log
2023-08-05 17:43:21 -07:00
Wyatt Verchere
d968ad383c Discord and playtime (#462)
* initial

* Fixed java thing

* fixes

* internet check change

* some fix/test commit

* Fix render issues on windows

* bump version

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Jai A <jai@modrinth.com>
2023-08-05 16:41:08 -07:00
Adrian O.V
5ee64f2705 New features (#477)
* Linking/Unlinking, Dir changing, CDN

* Fixes #435

* Progress bar

* Create splashscreen.html

* Run lint

* add rust part

* remove splashscreen code

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
2023-08-05 14:44:02 -07:00
93 changed files with 2028 additions and 865 deletions

2
.gitignore vendored
View File

@@ -110,3 +110,5 @@ fabric.properties
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
theseus.iml

13
Cargo.lock generated
View File

@@ -4609,7 +4609,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "0.4.0"
version = "0.5.2"
dependencies = [
"async-recursion",
"async-tungstenite",
@@ -4646,6 +4646,7 @@ dependencies = [
"tracing-error 0.1.2",
"tracing-subscriber 0.2.25",
"url",
"urlencoding",
"uuid 1.4.0",
"whoami",
"winreg 0.50.0",
@@ -4654,7 +4655,7 @@ dependencies = [
[[package]]
name = "theseus_cli"
version = "0.4.0"
version = "0.5.2"
dependencies = [
"argh",
"color-eyre",
@@ -4681,7 +4682,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "0.4.0"
version = "0.5.2"
dependencies = [
"chrono",
"cocoa",
@@ -5216,6 +5217,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"
version = "0.7.6"

11
theseus.iml Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/theseus/library" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus"
version = "0.4.0"
version = "0.5.2"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
edition = "2018"
@@ -21,6 +21,7 @@ uuid = { version = "1.1", features = ["serde", "v4"] }
zip = "0.6.5"
async_zip = { version = "0.0.13", features = ["full"] }
tempfile = "3.5.0"
urlencoding = "2.1.3"
chrono = { version = "0.4.19", features = ["serde"] }
daedalus = { version = "0.1.23" }

View File

@@ -1,7 +1,6 @@
//! Authentication flow interface
use crate::{launcher::auth as inner, State};
use crate::{hydra::init::DeviceLoginSuccess, launcher::auth as inner, State};
use chrono::Utc;
use tokio::sync::oneshot;
use crate::state::AuthTask;
pub use inner::Credentials;
@@ -11,7 +10,7 @@ pub use inner::Credentials;
/// This can be used in conjunction with 'authenticate_await_complete_flow'
/// to call authenticate and call the flow from the frontend.
/// Visit the URL in a browser, then call and await 'authenticate_await_complete_flow'.
pub async fn authenticate_begin_flow() -> crate::Result<url::Url> {
pub async fn authenticate_begin_flow() -> crate::Result<DeviceLoginSuccess> {
let url = AuthTask::begin_auth().await?;
Ok(url)
}
@@ -20,8 +19,7 @@ pub async fn authenticate_begin_flow() -> crate::Result<url::Url> {
/// This completes the authentication flow quasi-synchronously, returning the credentials
/// This can be used in conjunction with 'authenticate_begin_flow'
/// to call authenticate and call the flow from the frontend.
pub async fn authenticate_await_complete_flow(
) -> crate::Result<(Credentials, Option<String>)> {
pub async fn authenticate_await_complete_flow() -> crate::Result<Credentials> {
let credentials = AuthTask::await_auth_completion().await?;
Ok(credentials)
}
@@ -31,39 +29,6 @@ pub async fn cancel_flow() -> crate::Result<()> {
AuthTask::cancel().await
}
/// Authenticate a user with Hydra
/// To run this, you need to first spawn this function as a task, then
/// open a browser to the given URL and finally wait on the spawned future
/// with the ability to cancel in case the browser is closed before finishing
#[tracing::instrument]
#[theseus_macros::debug_pin]
pub async fn authenticate(
browser_url: oneshot::Sender<url::Url>,
) -> crate::Result<(Credentials, Option<String>)> {
let mut flow = inner::HydraAuthFlow::new().await?;
let state = State::get().await?;
let url = flow.prepare_login_url().await?;
browser_url.send(url).map_err(|url| {
crate::ErrorKind::OtherError(format!(
"Error sending browser url to parent: {url}"
))
})?;
let credentials = flow.extract_credentials(&state.fetch_semaphore).await?;
{
let mut users = state.users.write().await;
users.insert(&credentials.0).await?;
}
if state.settings.read().await.default_user.is_none() {
let mut settings = state.settings.write().await;
settings.default_user = Some(credentials.0.id);
}
Ok(credentials)
}
/// Refresh some credentials using Hydra, if needed
/// This is the primary desired way to get credentials, as it will also refresh them.
#[tracing::instrument]

View File

@@ -0,0 +1,85 @@
//! Main authentication flow for Hydra
use serde::Deserialize;
use crate::prelude::Credentials;
use super::stages::{
bearer_token, player_info, poll_response, xbl_signin, xsts_token,
};
#[derive(Debug, Deserialize)]
pub struct OauthFailure {
pub error: String,
}
pub struct SuccessfulLogin {
pub name: String,
pub icon: String,
pub token: String,
pub refresh_token: String,
pub expires_after: i64,
}
pub async fn wait_finish(device_code: String) -> crate::Result<Credentials> {
// Loop, polling for response from Microsoft
let oauth = poll_response::poll_response(device_code).await?;
// Get xbl token from oauth token
let xbl_token = xbl_signin::login_xbl(&oauth.access_token).await?;
// Get xsts token from xbl token
let xsts_response = xsts_token::fetch_token(&xbl_token.token).await?;
match xsts_response {
xsts_token::XSTSResponse::Unauthorized(err) => {
Err(crate::ErrorKind::HydraError(format!(
"Error getting XBox Live token: {}",
err
))
.as_error())
}
xsts_token::XSTSResponse::Success { token: xsts_token } => {
// Get xsts bearer token from xsts token
let bearer_token =
bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs)
.await
.map_err(|err| {
crate::ErrorKind::HydraError(format!(
"Error getting bearer token: {}",
err
))
})?;
// Get player info from bearer token
let player_info = player_info::fetch_info(&bearer_token).await.map_err(|_err| {
crate::ErrorKind::HydraError("No Minecraft account for profile. Make sure you own the game and have set a username through the official Minecraft launcher."
.to_string())
})?;
// Create credentials
let credentials = Credentials::new(
uuid::Uuid::parse_str(&player_info.id)?, // get uuid from player_info.id which is a String
player_info.name,
bearer_token,
oauth.refresh_token,
chrono::Utc::now()
+ chrono::Duration::seconds(oauth.expires_in),
);
// Put credentials into state
let state = crate::State::get().await?;
{
let mut users = state.users.write().await;
users.insert(&credentials).await?;
}
if state.settings.read().await.default_user.is_none() {
let mut settings = state.settings.write().await;
settings.default_user = Some(credentials.id);
}
Ok(credentials)
}
}
}

View File

@@ -0,0 +1,45 @@
//! 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};
use super::MICROSOFT_CLIENT_ID;
#[derive(Serialize, Deserialize, Debug)]
pub struct DeviceLoginSuccess {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
pub message: String,
}
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 req = REQWEST_CLIENT.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
.header("Content-Type", "application/x-www-form-urlencoded").form(&params).send().await?;
match req.status() {
reqwest::StatusCode::OK => Ok(req.json().await?),
_ => {
let microsoft_error = req.json::<MicrosoftError>().await?;
Err(crate::ErrorKind::HydraError(format!(
"Error from Microsoft: {:?}",
microsoft_error.error_description
))
.into())
}
}
}

View File

@@ -0,0 +1,15 @@
pub mod complete;
pub mod init;
pub mod refresh;
mod stages;
use serde::Deserialize;
const MICROSOFT_CLIENT_ID: &str = "c4502edb-87c6-40cb-b595-64a280cf8906";
#[derive(Deserialize)]
pub struct MicrosoftError {
pub error: String,
pub error_description: String,
pub error_codes: Vec<u64>,
}

View File

@@ -0,0 +1,60 @@
use std::collections::HashMap;
use reqwest::StatusCode;
use serde::Deserialize;
use crate::{
hydra::{MicrosoftError, MICROSOFT_CLIENT_ID},
util::fetch::REQWEST_CLIENT,
};
#[derive(Debug, Deserialize)]
pub struct OauthSuccess {
pub token_type: String,
pub scope: String,
pub expires_in: i64,
pub access_token: String,
pub refresh_token: String,
}
pub async fn refresh(refresh_token: String) -> crate::Result<OauthSuccess> {
let mut params = HashMap::new();
params.insert("grant_type", "refresh_token");
params.insert("client_id", MICROSOFT_CLIENT_ID);
params.insert("refresh_token", &refresh_token);
// Poll the URL in a loop until we are successful.
// On an authorization_pending response, wait 5 seconds and try again.
let resp = REQWEST_CLIENT
.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&params)
.send()
.await?;
match resp.status() {
StatusCode::OK => {
let oauth = resp.json::<OauthSuccess>().await.map_err(|err| {
crate::ErrorKind::HydraError(format!(
"Could not decipher successful response: {}",
err
))
})?;
Ok(oauth)
}
_ => {
let failure =
resp.json::<MicrosoftError>().await.map_err(|err| {
crate::ErrorKind::HydraError(format!(
"Could not decipher failure response: {}",
err
))
})?;
Err(crate::ErrorKind::HydraError(format!(
"Error refreshing token: {}",
failure.error
))
.as_error())
}
}
}

View File

@@ -0,0 +1,29 @@
use serde_json::json;
const MCSERVICES_AUTH_URL: &str =
"https://api.minecraftservices.com/launcher/login";
pub async fn fetch_bearer(token: &str, uhs: &str) -> crate::Result<String> {
let client = reqwest::Client::new();
let body = client
.post(MCSERVICES_AUTH_URL)
.json(&json!({
"xtoken": format!("XBL3.0 x={};{}", uhs, token),
"platform": "PC_LAUNCHER"
}))
.send()
.await?
.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(
crate::ErrorKind::HydraError(format!(
"Response didn't contain valid bearer token. body: {body}"
))
.into(),
)
}

View File

@@ -0,0 +1,7 @@
//! MSA authentication stages
pub mod bearer_token;
pub mod player_info;
pub mod poll_response;
pub mod xbl_signin;
pub mod xsts_token;

View File

@@ -0,0 +1,33 @@
//! Fetch player info for display
use serde::Deserialize;
const PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
#[derive(Deserialize)]
pub struct PlayerInfo {
pub id: String,
pub name: String,
}
impl Default for PlayerInfo {
fn default() -> Self {
Self {
id: "606e2ff0ed7748429d6ce1d3321c7838".to_string(),
name: String::from("???"),
}
}
}
pub async fn fetch_info(token: &str) -> crate::Result<PlayerInfo> {
let client = reqwest::Client::new();
let resp = client
.get(PROFILE_URL)
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}

View File

@@ -0,0 +1,91 @@
use std::collections::HashMap;
use reqwest::StatusCode;
use serde::Deserialize;
use crate::{
hydra::{MicrosoftError, MICROSOFT_CLIENT_ID},
util::fetch::REQWEST_CLIENT,
};
#[derive(Debug, Deserialize)]
pub struct OauthSuccess {
pub token_type: String,
pub scope: String,
pub expires_in: i64,
pub access_token: String,
pub refresh_token: String,
}
pub async fn poll_response(device_code: String) -> crate::Result<OauthSuccess> {
let mut params = HashMap::new();
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);
// Poll the URL in a loop until we are successful.
// On an authorization_pending response, wait 5 seconds and try again.
loop {
let resp = REQWEST_CLIENT
.post(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
)
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&params)
.send()
.await?;
match resp.status() {
StatusCode::OK => {
let oauth =
resp.json::<OauthSuccess>().await.map_err(|err| {
crate::ErrorKind::HydraError(format!(
"Could not decipher successful response: {}",
err
))
})?;
return Ok(oauth);
}
_ => {
let failure =
resp.json::<MicrosoftError>().await.map_err(|err| {
crate::ErrorKind::HydraError(format!(
"Could not decipher failure response: {}",
err
))
})?;
match failure.error.as_str() {
"authorization_pending" => {
tokio::time::sleep(std::time::Duration::from_secs(2))
.await;
}
"authorization_declined" => {
return Err(crate::ErrorKind::HydraError(
"Authorization declined".to_string(),
)
.as_error());
}
"expired_token" => {
return Err(crate::ErrorKind::HydraError(
"Device code expired".to_string(),
)
.as_error());
}
"bad_verification_code" => {
return Err(crate::ErrorKind::HydraError(
"Invalid device code".to_string(),
)
.as_error());
}
_ => {
return Err(crate::ErrorKind::HydraError(format!(
"Unknown error: {}",
failure.error
))
.as_error());
}
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
use serde_json::json;
const XBL_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
// Deserialization
pub struct XBLLogin {
pub token: String,
pub uhs: String,
}
// Impl
pub async fn login_xbl(token: &str) -> crate::Result<XBLLogin> {
let client = reqwest::Client::new();
let body = client
.post(XBL_AUTH_URL)
.header(reqwest::header::ACCEPT, "application/json")
.header("x-xbl-contract-version", "1")
.json(&json!({
"Properties": {
"AuthMethod": "RPS",
"SiteName": "user.auth.xboxlive.com",
"RpsTicket": format!("d={token}")
},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT"
}))
.send()
.await?
.text()
.await?;
let json = serde_json::from_str::<serde_json::Value>(&body)?;
let token = Some(&json)
.and_then(|it| it.get("Token")?.as_str().map(String::from))
.ok_or(crate::ErrorKind::HydraError(
"XBL response didn't contain valid token".to_string(),
))?;
let uhs = Some(&json)
.and_then(|it| {
it.get("DisplayClaims")?
.get("xui")?
.get(0)?
.get("uhs")?
.as_str()
.map(String::from)
})
.ok_or(
crate::ErrorKind::HydraError(
"XBL response didn't contain valid user hash".to_string(),
)
.as_error(),
)?;
Ok(XBLLogin { token, uhs })
}

View File

@@ -0,0 +1,56 @@
use serde_json::json;
const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
pub enum XSTSResponse {
Unauthorized(String),
Success { token: String },
}
pub async fn fetch_token(token: &str) -> crate::Result<XSTSResponse> {
let client = reqwest::Client::new();
let resp = client
.post(XSTS_AUTH_URL)
.header(reqwest::header::ACCEPT, "application/json")
.json(&json!({
"Properties": {
"SandboxId": "RETAIL",
"UserTokens": [
token
]
},
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT"
}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
let json = serde_json::from_str::<serde_json::Value>(&body)?;
if status.is_success() {
Ok(json
.get("Token")
.and_then(|x| x.as_str().map(String::from))
.map(|it| XSTSResponse::Success { token: it })
.unwrap_or(XSTSResponse::Unauthorized(
"XSTS response didn't contain valid token!".to_string(),
)))
} else {
Ok(XSTSResponse::Unauthorized(
#[allow(clippy::unreadable_literal)]
match json.get("XErr").and_then(|x| x.as_i64()) {
Some(2148916238) => {
String::from("This Microsoft account is underage and is not linked to a family.")
},
Some(2148916235) => {
String::from("XBOX Live/Minecraft is not available in your country.")
},
Some(2148916233) => String::from("This account does not have a valid XBOX Live profile. Please buy Minecraft and try again!"),
Some(2148916236) | Some(2148916237) => String::from("This account needs adult verification on Xbox page."),
_ => String::from("Unknown error code"),
},
))
}
}

View File

@@ -6,7 +6,7 @@ use std::path::PathBuf;
use crate::event::emit::{emit_loading, init_loading};
use crate::state::CredentialsStore;
use crate::util::fetch::{fetch_advanced, fetch_json};
use crate::util::io;
use crate::util::jre::extract_java_majorminor_version;
use crate::{
state::JavaGlobals,
@@ -117,10 +117,6 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
let path = state.directories.java_versions_dir().await;
if path.exists() {
io::remove_dir_all(&path).await?;
}
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(file))
.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(

View File

@@ -1,6 +1,7 @@
//! API for interacting with Theseus
pub mod auth;
pub mod handler;
pub mod hydra;
pub mod jre;
pub mod logs;
pub mod metadata;

View File

@@ -7,10 +7,13 @@ use crate::ErrorKind;
pub async fn authenticate_begin_flow(provider: &str) -> crate::Result<String> {
let state = crate::State::get().await?;
// Don't start an uncompleteable new flow if there's an existing locked one
let mut write: tokio::sync::RwLockWriteGuard<'_, Option<ModrinthAuthFlow>> =
state.modrinth_auth_flow.write().await;
let mut flow = ModrinthAuthFlow::new(provider).await?;
let url = flow.prepare_login_url().await?;
let mut write = state.modrinth_auth_flow.write().await;
*write = Some(flow);
Ok(url)

View File

@@ -3,13 +3,12 @@ use std::{collections::HashMap, path::PathBuf};
use serde::{Deserialize, Serialize};
use crate::{
event::LoadingBarId,
pack::{
self,
import::{self, copy_dotminecraft},
install_from::CreatePackDescription,
},
prelude::{ModLoader, ProfilePathId},
prelude::{ModLoader, Profile, ProfilePathId},
state::{LinkedData, ProfileInstallStage},
util::io,
State,
@@ -33,8 +32,6 @@ pub struct ATLauncher {
pub modrinth_project: Option<ATLauncherModrinthProject>,
pub modrinth_version: Option<ATLauncherModrinthVersion>,
pub modrinth_manifest: Option<pack::install_from::PackFormat>,
pub mods: Vec<ATLauncherMod>,
}
#[derive(Serialize, Deserialize)]
@@ -57,13 +54,9 @@ pub struct ATLauncherModrinthProject {
pub slug: String,
pub project_type: String,
pub team: String,
pub title: String,
pub description: String,
pub body: String,
pub client_side: Option<String>,
pub server_side: Option<String>,
pub categories: Vec<String>,
pub icon_url: String,
}
#[derive(Serialize, Deserialize, Debug)]
@@ -110,7 +103,16 @@ pub async fn is_valid_atlauncher(instance_folder: PathBuf) -> bool {
.unwrap_or("".to_string());
let instance: Result<ATInstance, serde_json::Error> =
serde_json::from_str::<ATInstance>(&instance);
instance.is_ok()
if let Err(e) = instance {
tracing::warn!(
"Could not parse instance.json at {}: {}",
instance_folder.display(),
e
);
false
} else {
true
}
}
#[tracing::instrument]
@@ -169,7 +171,6 @@ pub async fn import_atlauncher(
backup_name,
description,
atinstance,
None,
)
.await?;
Ok(())
@@ -181,7 +182,6 @@ async fn import_atlauncher_unmanaged(
backup_name: String,
description: CreatePackDescription,
atinstance: ATInstance,
existing_loading_bar: Option<LoadingBarId>,
) -> crate::Result<()> {
let mod_loader = format!(
"\"{}\"",
@@ -230,19 +230,28 @@ async fn import_atlauncher_unmanaged(
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
let state = State::get().await?;
copy_dotminecraft(
let loading_bar = copy_dotminecraft(
profile_path.clone(),
minecraft_folder,
&state.io_semaphore,
None,
)
.await?;
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, existing_loading_bar)
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile_val.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
}
State::sync().await?;
}
Ok(())

View File

@@ -2,6 +2,7 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::prelude::Profile;
use crate::state::CredentialsStore;
use crate::{
prelude::{ModLoader, ProfilePathId},
@@ -187,18 +188,29 @@ pub async fn import_curseforge(
// Copy in contained folders as overrides
let state = State::get().await?;
copy_dotminecraft(
let loading_bar = copy_dotminecraft(
profile_path.clone(),
curseforge_instance_folder,
&state.io_semaphore,
None,
)
.await?;
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, None).await?;
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile_val.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
}
State::sync().await?;
}

View File

@@ -3,7 +3,7 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::{
prelude::{ModLoader, ProfilePathId},
prelude::{ModLoader, Profile, ProfilePathId},
state::ProfileInstallStage,
util::io,
State,
@@ -101,18 +101,28 @@ pub async fn import_gdlauncher(
// Copy in contained folders as overrides
let state = State::get().await?;
copy_dotminecraft(
let loading_bar = copy_dotminecraft(
profile_path.clone(),
gdlauncher_instance_folder,
&state.io_semaphore,
None,
)
.await?;
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, None).await?;
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile_val.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
}
State::sync().await?;
}

View File

@@ -7,7 +7,7 @@ use crate::{
import::{self, copy_dotminecraft},
install_from::{self, CreatePackDescription, PackDependency},
},
prelude::ProfilePathId,
prelude::{Profile, ProfilePathId},
util::io,
State,
};
@@ -119,6 +119,26 @@ pub struct MMCComponentRequirement {
pub suggests: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
#[serde(untagged)]
enum MMCLauncherEnum {
General(MMCLauncherGeneral),
Instance(MMCLauncher),
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct MMCLauncherGeneral {
pub general: MMCLauncher,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
pub struct MMCLauncher {
instance_dir: String,
}
// Checks if if its a folder, and the folder contains instance.cfg and mmc-pack.json, and they both parse
#[tracing::instrument]
pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool {
@@ -134,9 +154,19 @@ pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool {
&& serde_json::from_str::<MMCPack>(&mmc_pack).is_ok()
}
#[tracing::instrument]
pub async fn get_instances_subpath(config: PathBuf) -> Option<String> {
let launcher = io::read_to_string(&config).await.ok()?;
let launcher: MMCLauncherEnum = serde_ini::from_str(&launcher).ok()?;
match launcher {
MMCLauncherEnum::General(p) => Some(p.general.instance_dir),
MMCLauncherEnum::Instance(p) => Some(p.instance_dir),
}
}
// Loading the INI (instance.cfg) file
async fn load_instance_cfg(file_path: &Path) -> crate::Result<MMCInstance> {
let instance_cfg = io::read_to_string(file_path).await?;
let instance_cfg: String = io::read_to_string(file_path).await?;
let instance_cfg_enum: MMCInstanceEnum =
serde_ini::from_str::<MMCInstanceEnum>(&instance_cfg)?;
match instance_cfg_enum {
@@ -281,18 +311,28 @@ async fn import_mmc_unmanaged(
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
let state = State::get().await?;
copy_dotminecraft(
let loading_bar = copy_dotminecraft(
profile_path.clone(),
minecraft_folder,
&state.io_semaphore,
None,
)
.await?;
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, None).await?;
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile_val.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
}
State::sync().await?;
}
Ok(())

View File

@@ -7,6 +7,10 @@ use io::IOError;
use serde::{Deserialize, Serialize};
use crate::{
event::{
emit::{emit_loading, init_or_edit_loading},
LoadingBarId,
},
prelude::ProfilePathId,
state::Profiles,
util::{
@@ -51,11 +55,20 @@ pub async fn get_importable_instances(
) -> crate::Result<Vec<String>> {
// Some launchers have a different folder structure for instances
let instances_subfolder = match launcher_type {
ImportLauncherType::GDLauncher
| ImportLauncherType::MultiMC
| ImportLauncherType::PrismLauncher
| ImportLauncherType::ATLauncher => "instances",
ImportLauncherType::Curseforge => "Instances",
ImportLauncherType::GDLauncher | ImportLauncherType::ATLauncher => {
"instances".to_string()
}
ImportLauncherType::Curseforge => "Instances".to_string(),
ImportLauncherType::MultiMC => {
mmc::get_instances_subpath(base_path.clone().join("multimc.cfg"))
.await
.unwrap_or_else(|| "instances".to_string())
}
ImportLauncherType::PrismLauncher => mmc::get_instances_subpath(
base_path.clone().join("prismlauncher.cfg"),
)
.await
.unwrap_or_else(|| "instances".to_string()),
ImportLauncherType::Unknown => {
return Err(crate::ErrorKind::InputError(
"Launcher type Unknown".to_string(),
@@ -63,7 +76,8 @@ pub async fn get_importable_instances(
.into())
}
};
let instances_folder = base_path.join(instances_subfolder);
let instances_folder = base_path.join(&instances_subfolder);
let mut instances = Vec::new();
let mut dir = io::read_dir(&instances_folder).await.map_err(| _ | {
crate::ErrorKind::InputError(format!(
@@ -238,55 +252,61 @@ pub async fn recache_icon(
}
async fn copy_dotminecraft(
profile_path: ProfilePathId,
profile_path_id: ProfilePathId,
dotminecraft: PathBuf,
io_semaphore: &IoSemaphore,
) -> crate::Result<()> {
existing_loading_bar: Option<LoadingBarId>,
) -> crate::Result<LoadingBarId> {
// Get full path to profile
let profile_path = profile_path.get_full_path().await?;
let profile_path = profile_path_id.get_full_path().await?;
// std fs copy every file in dotminecraft to profile_path
let mut dir = io::read_dir(&dotminecraft).await?;
while let Some(entry) = dir
.next_entry()
.await
.map_err(|e| IOError::with_path(e, &dotminecraft))?
{
let path = entry.path();
copy_dir_to(
&path,
&profile_path.join(path.file_name().ok_or_else(|| {
// Gets all subfiles recursively in src
let subfiles = get_all_subfiles(&dotminecraft).await?;
let total_subfiles = subfiles.len() as u64;
let loading_bar = init_or_edit_loading(
existing_loading_bar,
crate::LoadingBarType::CopyProfile {
import_location: dotminecraft.clone(),
profile_name: profile_path_id.to_string(),
},
total_subfiles as f64,
"Copying files in profile",
)
.await?;
// Copy each file
for src_child in subfiles {
let dst_child =
src_child.strip_prefix(&dotminecraft).map_err(|_| {
crate::ErrorKind::InputError(format!(
"Invalid file: {}",
&path.display()
&src_child.display()
))
})?),
io_semaphore,
)
.await?;
})?;
let dst_child = profile_path.join(dst_child);
// sleep for cpu for 1 millisecond
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
fetch::copy(&src_child, &dst_child, io_semaphore).await?;
emit_loading(&loading_bar, 1.0, None).await?;
}
Ok(())
Ok(loading_bar)
}
/// Recursively fs::copy every file in src to dest
/// Recursively get a list of all subfiles in src
/// uses async recursion
#[theseus_macros::debug_pin]
#[async_recursion::async_recursion]
#[tracing::instrument]
async fn copy_dir_to(
src: &Path,
dst: &Path,
io_semaphore: &IoSemaphore,
) -> crate::Result<()> {
async fn get_all_subfiles(src: &Path) -> crate::Result<Vec<PathBuf>> {
if !src.is_dir() {
fetch::copy(src, dst, io_semaphore).await?;
return Ok(());
return Ok(vec![src.to_path_buf()]);
}
// Create the destination directory
io::create_dir_all(&dst).await?;
// Iterate over the directory
let mut files = Vec::new();
let mut dir = io::read_dir(&src).await?;
while let Some(child) = dir
.next_entry()
@@ -294,21 +314,7 @@ async fn copy_dir_to(
.map_err(|e| IOError::with_path(e, src))?
{
let src_child = child.path();
let dst_child = dst.join(src_child.file_name().ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Invalid file: {}",
&src_child.display()
))
})?);
if src_child.is_dir() {
// Recurse into sub-directory
copy_dir_to(&src_child, &dst_child, io_semaphore).await?;
} else {
// Copy file
fetch::copy(&src_child, &dst_child, io_semaphore).await?;
}
files.append(&mut get_all_subfiles(&src_child).await?);
}
Ok(())
Ok(files)
}

View File

@@ -65,7 +65,7 @@ pub enum EnvType {
Server,
}
#[derive(Serialize, Deserialize, Clone, Hash, PartialEq, Eq, Debug)]
#[derive(Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum PackDependency {
Forge,
@@ -101,6 +101,7 @@ pub struct CreatePackProfile {
pub icon_url: Option<String>, // the URL icon for a profile (ONLY USED FOR TEMPORARY PROFILES)
pub linked_data: Option<LinkedData>, // the linked project ID (mainly for modpacks)- used for updating
pub skip_install_profile: Option<bool>,
pub no_watch: Option<bool>,
}
// default
@@ -115,6 +116,7 @@ impl Default for CreatePackProfile {
icon_url: None,
linked_data: None,
skip_install_profile: Some(true),
no_watch: Some(false),
}
}
}

View File

@@ -273,9 +273,7 @@ pub async fn install_zipped_mrpack_files(
profile::edit_icon(&profile_path, Some(&potential_icon)).await?;
}
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
if let Some(profile_val) = profile::get(&profile_path, None).await? {
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;

View File

@@ -32,6 +32,7 @@ pub async fn profile_create(
icon_url: Option<String>, // the URL icon for a profile (ONLY USED FOR TEMPORARY PROFILES)
linked_data: Option<LinkedData>, // the linked project ID (mainly for modpacks)- used for updating
skip_install_profile: Option<bool>,
no_watch: Option<bool>,
) -> crate::Result<ProfilePathId> {
name = profile::sanitize_profile_name(&name);
@@ -112,7 +113,9 @@ pub async fn profile_create(
{
let mut profiles = state.profiles.write().await;
profiles.insert(profile.clone()).await?;
profiles
.insert(profile.clone(), no_watch.unwrap_or_default())
.await?;
}
if !skip_install_profile.unwrap_or(false) {
@@ -146,6 +149,7 @@ pub async fn profile_create_from_creator(
profile.icon_url,
profile.linked_data,
profile.skip_install_profile,
profile.no_watch,
)
.await
}

View File

@@ -10,6 +10,7 @@ use crate::pack::install_from::{
use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId};
use crate::state::ProjectMetadata;
use crate::util::fetch;
use crate::util::io::{self, IOError};
use crate::{
auth::{self, refresh},
@@ -22,6 +23,7 @@ pub use crate::{
};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use serde_json::json;
use std::collections::HashMap;
@@ -541,23 +543,6 @@ pub async fn remove_project(
}
}
/// Gets whether project is a managed modrinth pack
#[tracing::instrument]
pub async fn is_managed_modrinth_pack(
profile: &ProfilePathId,
) -> crate::Result<bool> {
if let Some(profile) = get(profile, None).await? {
if let Some(linked_data) = profile.metadata.linked_data {
return Ok(linked_data.project_id.is_some()
&& linked_data.version_id.is_some());
}
Ok(false)
} else {
Err(crate::ErrorKind::UnmanagedProfileError(profile.to_string())
.as_error())
}
}
/// Exports the profile to a Modrinth-formatted .mrpack file
// Version ID of uploaded version (ie 1.1.5), not the unique identifying ID of the version (nvrqJg44)
#[tracing::instrument(skip_all)]
@@ -878,6 +863,65 @@ pub async fn run_credentials(
Ok(mc_process)
}
/// Update playtime- sending a request to the server to update the playtime
#[tracing::instrument]
#[theseus_macros::debug_pin]
pub async fn try_update_playtime(path: &ProfilePathId) -> crate::Result<()> {
let state = State::get().await?;
let profile = get(path, None).await?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to update playtime for a nonexistent or unloaded profile at path {}!",
path
))
})?;
let updated_recent_playtime = profile.metadata.recent_time_played;
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);
let playtime_update_json = json!({
"seconds": updated_recent_playtime,
"loader": profile.metadata.loader.to_string(),
"game_version": profile.metadata.game_version,
"parent": modrinth_pack_version_id,
});
// Copy this struct for every Modrinth project in the profile
let mut hashmap: HashMap<String, serde_json::Value> = HashMap::new();
for (_, project) in profile.projects {
if let ProjectMetadata::Modrinth { version, .. } = project.metadata
{
hashmap.insert(version.id, playtime_update_json.clone());
}
}
let creds = state.credentials.read().await;
fetch::post_json(
"https://api.modrinth.com/analytics/playtime",
serde_json::to_value(hashmap)?,
&state.fetch_semaphore,
&creds,
)
.await
} else {
Ok(())
};
// If successful, update the profile metadata to match submitted
if res.is_ok() {
let mut profiles = state.profiles.write().await;
if let Some(profile) = profiles.0.get_mut(path) {
profile.metadata.submitted_time_played += updated_recent_playtime;
profile.metadata.recent_time_played = 0;
}
}
// Sync either way
State::sync().await?;
res
}
fn get_modrinth_pack_list(packfile: &PackFormat) -> Vec<String> {
packfile
.files
@@ -930,7 +974,7 @@ pub async fn create_mrpack_json(
// But the values are sanitized to only include the version number
let dependencies = dependencies
.into_iter()
.map(|(k, v)| (k, sanitize_loader_version_string(&v).to_string()))
.map(|(k, v)| (k, sanitize_loader_version_string(&v, k).to_string()))
.collect::<HashMap<_, _>>();
let files: Result<Vec<PackFile>, crate::ErrorKind> = profile
@@ -999,18 +1043,26 @@ pub async fn create_mrpack_json(
})
}
fn sanitize_loader_version_string(s: &str) -> &str {
// Split on '-'
// If two or more, take the second
// If one, take the first
// If none, take the whole thing
let mut split: std::str::Split<'_, char> = s.split('-');
match split.next() {
Some(first) => match split.next() {
Some(second) => second,
None => first,
},
None => s,
fn sanitize_loader_version_string(s: &str, loader: PackDependency) -> &str {
match loader {
// Split on '-'
// If two or more, take the second
// If one, take the first
// If none, take the whole thing
PackDependency::Forge => {
let mut split: std::str::Split<'_, char> = s.split('-');
match split.next() {
Some(first) => match split.next() {
Some(second) => second,
None => first,
},
None => s,
}
}
// For quilt, etc we take the whole thing, as it functions like: 0.20.0-beta.11 (and should not be split here)
PackDependency::QuiltLoader
| PackDependency::FabricLoader
| PackDependency::Minecraft => s,
}
}
@@ -1037,5 +1089,5 @@ pub async fn build_folder(
}
pub fn sanitize_profile_name(input: &str) -> String {
input.replace(['/', '\\', ':'], "_")
input.replace(['/', '\\', '?', '*', ':', '\'', '\"', '|', '<', '>'], "_")
}

View File

@@ -185,6 +185,10 @@ pub enum LoadingBarType {
ConfigChange {
new_path: PathBuf,
},
CopyProfile {
import_location: PathBuf,
profile_name: String,
},
}
#[derive(Serialize, Clone)]

View File

@@ -268,8 +268,8 @@ fn parse_minecraft_argument(
.replace("${auth_player_name}", username)
// TODO: add auth xuid eventually
.replace("${auth_xuid}", "0")
.replace("${auth_uuid}", &uuid.hyphenated().to_string())
.replace("${uuid}", &uuid.hyphenated().to_string())
.replace("${auth_uuid}", &uuid.simple().to_string())
.replace("${uuid}", &uuid.simple().to_string())
.replace("${clientid}", "c4502edb-87c6-40cb-b595-64a280cf8906")
.replace("${user_properties}", "{}")
.replace("${user_type}", "msa")

View File

@@ -1,55 +1,11 @@
//! Authentication flow based on Hydra
use crate::config::MODRINTH_API_URL;
use crate::state::CredentialsStore;
use crate::util::fetch::{fetch_advanced, fetch_json, FetchSemaphore};
use async_tungstenite as ws;
use crate::hydra;
use crate::util::fetch::FetchSemaphore;
use chrono::{prelude::*, Duration};
use futures::prelude::*;
use lazy_static::lazy_static;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use url::Url;
lazy_static! {
static ref HYDRA_URL: Url =
Url::parse(&format!("{MODRINTH_API_URL}auth/minecraft/"))
.expect("Hydra URL parse failed");
}
// Socket messages
#[derive(Deserialize)]
struct ErrorJSON {
error: String,
}
impl ErrorJSON {
pub fn unwrap<'a, T: Deserialize<'a>>(data: &'a [u8]) -> crate::Result<T> {
if let Ok(err) = serde_json::from_slice::<Self>(data) {
Err(crate::ErrorKind::HydraError(err.error).as_error())
} else {
Ok(serde_json::from_slice::<T>(data)?)
}
}
}
#[derive(Deserialize)]
struct LoginCodeJSON {
login_code: String,
}
#[derive(Deserialize)]
struct TokenJSON {
token: String,
refresh_token: String,
expires_after: u32,
flow: Option<String>,
}
#[derive(Deserialize)]
struct ProfileInfoJSON {
id: uuid::Uuid,
name: String,
}
// Login information
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -62,116 +18,39 @@ pub struct Credentials {
_ctor_scope: std::marker::PhantomData<()>,
}
// Implementation
pub struct HydraAuthFlow<S: AsyncRead + AsyncWrite + Unpin> {
socket: ws::WebSocketStream<S>,
}
impl HydraAuthFlow<ws::tokio::ConnectStream> {
pub async fn new() -> crate::Result<Self> {
let (socket, _) = ws::tokio::connect_async(
"wss://api.modrinth.com/v2/auth/minecraft/ws",
)
.await?;
Ok(Self { socket })
impl Credentials {
pub fn new(
id: uuid::Uuid,
username: String,
access_token: String,
refresh_token: String,
expires: DateTime<Utc>,
) -> Self {
Self {
id,
username,
access_token,
refresh_token,
expires,
_ctor_scope: std::marker::PhantomData,
}
}
pub async fn prepare_login_url(&mut self) -> crate::Result<Url> {
let code_resp = self
.socket
.try_next()
.await?
.ok_or(
crate::ErrorKind::WSClosedError(String::from(
"login socket ID",
))
.as_error(),
)?
.into_data();
let code = ErrorJSON::unwrap::<LoginCodeJSON>(&code_resp)?;
Ok(wrap_ref_builder!(
it = HYDRA_URL.join("init")? =>
{ it.query_pairs_mut().append_pair("id", &code.login_code); }
))
}
pub async fn extract_credentials(
&mut self,
semaphore: &FetchSemaphore,
) -> crate::Result<(Credentials, Option<String>)> {
// Minecraft bearer token
let token_resp = self
.socket
.try_next()
.await?
.ok_or(
crate::ErrorKind::WSClosedError(String::from(
"login socket ID",
))
.as_error(),
)?
.into_data();
let token = ErrorJSON::unwrap::<TokenJSON>(&token_resp)?;
let expires =
Utc::now() + Duration::seconds(token.expires_after.into());
// Get account credentials
let info = fetch_info(&token.token, semaphore).await?;
// Return structure from response
Ok((
Credentials {
username: info.name,
id: info.id,
refresh_token: token.refresh_token,
access_token: token.token,
expires,
_ctor_scope: std::marker::PhantomData,
},
token.flow,
))
pub fn is_expired(&self) -> bool {
self.expires < Utc::now()
}
}
pub async fn refresh_credentials(
credentials: &mut Credentials,
semaphore: &FetchSemaphore,
_semaphore: &FetchSemaphore,
) -> crate::Result<()> {
let resp = fetch_json::<TokenJSON>(
Method::POST,
&format!("{MODRINTH_API_URL}auth/minecraft/refresh"),
None,
Some(serde_json::json!({ "refresh_token": credentials.refresh_token })),
semaphore,
&CredentialsStore(None),
)
.await?;
let res =
hydra::refresh::refresh(credentials.refresh_token.clone()).await?;
credentials.access_token = resp.token;
credentials.refresh_token = resp.refresh_token;
credentials.expires =
Utc::now() + Duration::seconds(resp.expires_after.into());
credentials.access_token = res.access_token;
credentials.refresh_token = res.refresh_token;
credentials.expires = Utc::now() + Duration::seconds(res.expires_in);
Ok(())
}
// Helpers
async fn fetch_info(
token: &str,
semaphore: &FetchSemaphore,
) -> crate::Result<ProfileInfoJSON> {
let result = fetch_advanced(
Method::GET,
"https://api.minecraftservices.com/minecraft/profile",
None,
None,
Some(("Authorization", &format!("Bearer {token}"))),
None,
semaphore,
&CredentialsStore(None),
)
.await?;
let value = serde_json::from_slice(&result)?;
Ok(value)
}

View File

@@ -320,7 +320,7 @@ pub async fn download_libraries(
let reader = std::io::Cursor::new(&data);
if let Ok(mut archive) = zip::ZipArchive::new(reader) {
match archive.extract(st.directories.version_natives_dir(version).await) {
Ok(_) => tracing::info!("Fetched native {}", &library.name),
Ok(_) => tracing::debug!("Fetched native {}", &library.name),
Err(err) => tracing::error!("Failed extracting native {}. err: {}", &library.name, err)
}
} else {

View File

@@ -103,6 +103,7 @@ pub async fn install_minecraft(
profile: &Profile,
existing_loading_bar: Option<LoadingBarId>,
) -> crate::Result<()> {
let sync_projects = existing_loading_bar.is_some();
let loading_bar = init_or_edit_loading(
existing_loading_bar,
LoadingBarType::MinecraftDownload {
@@ -123,6 +124,10 @@ pub async fn install_minecraft(
.await?;
State::sync().await?;
if sync_projects {
Profile::sync_projects_task(profile.profile_id(), true);
}
let state = State::get().await?;
let instance_path =
&io::canonicalize(&profile.get_profile_full_path().await?)?;
@@ -456,28 +461,28 @@ pub async fn launch_minecraft(
// Uses 'a:b' syntax which is not quite yaml
use regex::Regex;
let options_path = instance_path.join("options.txt");
let mut options_string = String::new();
if options_path.exists() {
options_string = io::read_to_string(&options_path).await?;
}
for (key, value) in mc_set_options {
let re = Regex::new(&format!(r"(?m)^{}:.*$", regex::escape(key)))?;
// check if the regex exists in the file
if !re.is_match(&options_string) {
// The key was not found in the file, so append it
options_string.push_str(&format!("\n{}:{}", key, value));
} else {
let replaced_string = re
.replace_all(&options_string, &format!("{}:{}", key, value))
.to_string();
options_string = replaced_string;
if !mc_set_options.is_empty() {
let options_path = instance_path.join("options.txt");
let mut options_string = String::new();
if options_path.exists() {
options_string = io::read_to_string(&options_path).await?;
}
for (key, value) in mc_set_options {
let re = Regex::new(&format!(r"(?m)^{}:.*$", regex::escape(key)))?;
// check if the regex exists in the file
if !re.is_match(&options_string) {
// The key was not found in the file, so append it
options_string.push_str(&format!("\n{}:{}", key, value));
} else {
let replaced_string = re
.replace_all(&options_string, &format!("{}:{}", key, value))
.to_string();
options_string = replaced_string;
}
}
}
io::write(&options_path, options_string).await?;
io::write(&options_path, options_string).await?;
}
// Get Modrinth logs directories
let datetime_string =

View File

@@ -1,4 +1,7 @@
use crate::launcher::auth::Credentials;
use crate::{
hydra::{self, init::DeviceLoginSuccess},
launcher::auth::Credentials,
};
use tokio::task::JoinHandle;
@@ -8,7 +11,7 @@ use tokio::task::JoinHandle;
pub struct AuthTask(
#[allow(clippy::type_complexity)]
Option<JoinHandle<crate::Result<(Credentials, Option<String>)>>>,
Option<JoinHandle<crate::Result<Credentials>>>,
);
impl AuthTask {
@@ -16,32 +19,24 @@ impl AuthTask {
AuthTask(None)
}
pub async fn begin_auth() -> crate::Result<url::Url> {
pub async fn begin_auth() -> crate::Result<DeviceLoginSuccess> {
let state = crate::State::get().await?;
// Init task, get url
let login = hydra::init::init().await?;
// Creates a channel to receive the URL
let (tx, rx) = tokio::sync::oneshot::channel::<url::Url>();
let task = tokio::spawn(crate::auth::authenticate(tx));
// If receiver is dropped, try to get Hydra error
let url = rx.await;
let url = match url {
Ok(url) => url,
Err(e) => {
task.await??;
return Err(e.into()); // truly a dropped receiver
}
};
// Await completion
let task = tokio::spawn(hydra::complete::wait_finish(
login.device_code.clone(),
));
// Flow is going, store in state and return
let mut write = state.auth_flow.write().await;
write.0 = Some(task);
Ok(url)
Ok(login)
}
pub async fn await_auth_completion(
) -> crate::Result<(Credentials, Option<String>)> {
pub async fn await_auth_completion() -> crate::Result<Credentials> {
// Gets the task handle from the state, replacing with None
let task = {
let state = crate::State::get().await?;

View File

@@ -1,4 +1,5 @@
use super::{Profile, ProfilePathId};
use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::{collections::HashMap, sync::Arc};
@@ -12,6 +13,7 @@ use tracing::error;
use crate::event::emit::emit_process;
use crate::event::ProcessPayloadType;
use crate::profile;
use crate::util::io::IOError;
use tokio::task::JoinHandle;
@@ -29,6 +31,7 @@ pub struct MinecraftChild {
pub manager: Option<JoinHandle<crate::Result<ExitStatus>>>, // None when future has completed and been handled
pub current_child: Arc<RwLock<Child>>,
pub output: SharedOutput,
pub last_updated_playtime: DateTime<Utc>, // The last time we updated the playtime for the associated profile
}
impl Children {
@@ -94,6 +97,7 @@ impl Children {
post_command,
pid,
current_child.clone(),
profile_relative_path.clone(),
)));
emit_process(
@@ -104,6 +108,8 @@ impl Children {
)
.await?;
let last_updated_playtime = Utc::now();
// Create MinecraftChild
let mchild = MinecraftChild {
uuid,
@@ -111,6 +117,7 @@ impl Children {
current_child,
output: shared_output,
manager,
last_updated_playtime,
};
let mchild = Arc::new(RwLock::new(mchild));
@@ -128,11 +135,13 @@ impl Children {
post_command: Option<Command>,
mut current_pid: u32,
current_child: Arc<RwLock<Child>>,
associated_profile: ProfilePathId,
) -> crate::Result<ExitStatus> {
let current_child = current_child.clone();
// Wait on current Minecraft Child
let mut mc_exit_status;
let mut last_updated_playtime = Utc::now();
loop {
if let Some(t) = current_child
.write()
@@ -145,8 +154,60 @@ impl Children {
}
// sleep for 10ms
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
// Auto-update playtime every minute
let diff = Utc::now()
.signed_duration_since(last_updated_playtime)
.num_seconds();
if diff >= 60 {
if let Err(e) = profile::edit(&associated_profile, |prof| {
prof.metadata.recent_time_played += diff as u64;
async { Ok(()) }
})
.await
{
tracing::warn!(
"Failed to update playtime for profile {}: {}",
associated_profile,
e
);
}
last_updated_playtime = Utc::now();
}
}
// Now fully complete- update playtime one last time
let diff = Utc::now()
.signed_duration_since(last_updated_playtime)
.num_seconds();
if let Err(e) = profile::edit(&associated_profile, |prof| {
prof.metadata.recent_time_played += diff as u64;
async { Ok(()) }
})
.await
{
tracing::warn!(
"Failed to update playtime for profile {}: {}",
associated_profile,
e
);
}
// Publish play time update
// Allow failure, it will be stored locally and sent next time
// Sent in another thread as first call may take a couple seconds and hold up process ending
tokio::spawn(async move {
if let Err(e) =
profile::try_update_playtime(&associated_profile).await
{
tracing::warn!(
"Failed to update playtime for profile {}: {}",
associated_profile,
e
);
}
});
{
// Clear game played for Discord RPC
// May have other active processes, so we clear to the next running process

View File

@@ -99,6 +99,7 @@ impl DiscordGuard {
Ok(())
}
/*
/// Clear the activity
pub async fn clear_activity(
&self,
@@ -137,7 +138,7 @@ impl DiscordGuard {
res.map_err(could_not_clear_err)?;
}
Ok(())
}
}*/
/// Clear the activity, but if there is a running profile, set the activity to that instead
pub async fn clear_to_default(
@@ -160,7 +161,7 @@ impl DiscordGuard {
)
.await?;
} else {
self.clear_activity(reconnect_if_fail).await?;
self.set_activity("Idling...", reconnect_if_fail).await?;
}
Ok(())
}

View File

@@ -156,7 +156,7 @@ impl State {
)));
emit_loading(&loading_bar, 10.0, None).await?;
let is_offline = !fetch::check_internet(&fetch_semaphore, 3).await;
let is_offline = !fetch::check_internet(3).await;
let metadata_fut =
Metadata::init(&directories, !is_offline, &io_semaphore);
@@ -185,6 +185,10 @@ impl State {
let safety_processes = SafeProcesses::new();
let discord_rpc = DiscordGuard::init().await?;
{
// Add default Idling to discord rich presence
let _ = discord_rpc.set_activity("Idling...", true).await;
}
// Starts a loop of checking if we are online, and updating
Self::offine_check_loop();
@@ -323,7 +327,7 @@ impl State {
/// Refreshes whether or not the launcher should be offline, by whether or not there is an internet connection
pub async fn refresh_offline(&self) -> crate::Result<()> {
let is_online = fetch::check_internet(&self.fetch_semaphore, 3).await;
let is_online = fetch::check_internet(3).await;
let mut offline = self.offline.write().await;
@@ -341,7 +345,7 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
let (mut tx, mut rx) = channel(1);
let file_watcher = new_debouncer(
Duration::from_secs_f32(0.25),
Duration::from_secs_f32(2.0),
None,
move |res: DebounceEventResult| {
futures::executor::block_on(async {
@@ -349,19 +353,17 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
})
},
)?;
tokio::task::spawn(async move {
let span = tracing::span!(tracing::Level::INFO, "init_watcher");
tracing::info!(parent: &span, "Initting watcher");
while let Some(res) = rx.next().await {
let _span = span.enter();
match res {
Ok(mut events) => {
let mut visited_paths = Vec::new();
// sort events by e.path
events.sort_by(|a, b| a.path.cmp(&b.path));
events.iter().for_each(|e| {
tracing::debug!(
"File watcher event: {:?}",
serde_json::to_string(&e.path).unwrap()
);
let mut new_path = PathBuf::new();
let mut components_iterator = e.path.components();
let mut found = false;
@@ -394,7 +396,10 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
Profile::crash_task(profile_path_id);
} else if !visited_paths.contains(&new_path) {
if subfile {
Profile::sync_projects_task(profile_path_id);
Profile::sync_projects_task(
profile_path_id,
false,
);
visited_paths.push(new_path);
} else {
Profiles::sync_available_profiles_task(

View File

@@ -222,7 +222,7 @@ pub async fn login_password(
) -> crate::Result<ModrinthCredentialsResult> {
let resp = fetch_advanced(
Method::POST,
&format!("https://{MODRINTH_API_URL}auth/login"),
&format!("{MODRINTH_API_URL}auth/login"),
None,
Some(serde_json::json!({
"username": username,

View File

@@ -183,6 +183,10 @@ pub struct ProfileMetadata {
pub date_modified: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_played: Option<DateTime<Utc>>,
#[serde(default)]
pub submitted_time_played: u64,
#[serde(default)]
pub recent_time_played: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -265,6 +269,8 @@ impl Profile {
date_created: Utc::now(),
date_modified: Utc::now(),
last_played: None,
submitted_time_played: 0,
recent_time_played: 0,
},
projects: HashMap::new(),
java: None,
@@ -324,47 +330,48 @@ impl Profile {
});
}
pub fn sync_projects_task(profile_path_id: ProfilePathId) {
pub fn sync_projects_task(profile_path_id: ProfilePathId, force: bool) {
let span = tracing::span!(
tracing::Level::INFO,
"sync_projects_task",
?profile_path_id,
?force
);
tokio::task::spawn(async move {
let span =
tracing::span!(tracing::Level::INFO, "sync_projects_task");
tracing::debug!(
parent: &span,
"Syncing projects for profile {}",
profile_path_id
);
let res = async {
let _span = span.enter();
let state = State::get().await?;
let profile = crate::api::profile::get(&profile_path_id, None).await?;
if let Some(profile) = profile {
let paths = profile.get_profile_full_project_paths().await?;
if profile.install_stage != ProfileInstallStage::PackInstalling || force {
let paths = profile.get_profile_full_project_paths().await?;
let caches_dir = state.directories.caches_dir();
let creds = state.credentials.read().await;
let projects = crate::state::infer_data_from_files(
profile.clone(),
paths,
caches_dir,
&state.io_semaphore,
&state.fetch_semaphore,
&creds,
)
.await?;
drop(creds);
let caches_dir = state.directories.caches_dir();
let creds = state.credentials.read().await;
let projects = crate::state::infer_data_from_files(
profile.clone(),
paths,
caches_dir,
&state.io_semaphore,
&state.fetch_semaphore,
&creds,
)
.await?;
drop(creds);
let mut new_profiles = state.profiles.write().await;
if let Some(profile) = new_profiles.0.get_mut(&profile_path_id) {
profile.projects = projects;
let mut new_profiles = state.profiles.write().await;
if let Some(profile) = new_profiles.0.get_mut(&profile_path_id) {
profile.projects = projects;
}
emit_profile(
profile.uuid,
&profile_path_id,
&profile.metadata.name,
ProfilePayloadType::Synced,
)
.await?;
}
emit_profile(
profile.uuid,
&profile_path_id,
&profile.metadata.name,
ProfilePayloadType::Synced,
)
.await?;
} else {
tracing::warn!(
"Unable to fetch single profile projects: path {profile_path_id} invalid",
@@ -832,12 +839,17 @@ impl Profiles {
drop(creds);
// Versions are pre-sorted in labrinth (by versions.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published));)
// so we can just take the first one
// so we can just take the first one for which the loader matches
let mut new_profiles = state.profiles.write().await;
if let Some(profile) =
new_profiles.0.get_mut(&profile_path)
{
if let Some(recent_version) = versions.get(0) {
let loader = profile.metadata.loader;
let recent_version = versions.iter().find(|x| {
x.loaders
.contains(&loader.as_api_str().to_string())
});
if let Some(recent_version) = recent_version {
profile.modrinth_update_version =
Some(recent_version.id.clone());
} else {
@@ -871,7 +883,11 @@ impl Profiles {
#[tracing::instrument(skip(self, profile))]
#[theseus_macros::debug_pin]
pub async fn insert(&mut self, profile: Profile) -> crate::Result<&Self> {
pub async fn insert(
&mut self,
profile: Profile,
no_watch: bool,
) -> crate::Result<&Self> {
emit_profile(
profile.uuid,
&profile.profile_id(),
@@ -880,13 +896,15 @@ impl Profiles {
)
.await?;
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
if !no_watch {
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
}
let profile_name = profile.profile_id();
profile_name.check_valid_utf()?;
@@ -978,9 +996,10 @@ impl Profiles {
dirs,
)
.await?,
false,
)
.await?;
Profile::sync_projects_task(profile_path_id);
Profile::sync_projects_task(profile_path_id, false);
}
Ok::<(), crate::Error>(())
}

View File

@@ -281,7 +281,6 @@ pub async fn infer_data_from_files(
) -> crate::Result<HashMap<ProjectPathId, Project>> {
let mut file_path_hashes = HashMap::new();
// TODO: Make this concurrent and use progressive hashing to avoid loading each JAR in memory
for path in paths {
if !path.exists() {
continue;
@@ -297,10 +296,19 @@ pub async fn infer_data_from_files(
.await
.map_err(|e| IOError::with_path(e, &path))?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).await.map_err(IOError::from)?;
let mut buffer = [0u8; 4096]; // Buffer to read chunks
let mut hasher = sha2::Sha512::new(); // Hasher
let hash = format!("{:x}", sha2::Sha512::digest(&buffer));
loop {
let bytes_read =
file.read(&mut buffer).await.map_err(IOError::from)?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let hash = format!("{:x}", hasher.finalize());
file_path_hashes.insert(hash, path.clone());
}

View File

@@ -20,7 +20,7 @@ pub struct IoSemaphore(pub RwLock<Semaphore>);
pub struct FetchSemaphore(pub RwLock<Semaphore>);
lazy_static! {
static ref REQWEST_CLIENT: reqwest::Client = {
pub static ref REQWEST_CLIENT: reqwest::Client = {
let mut headers = reqwest::header::HeaderMap::new();
let header = reqwest::header::HeaderValue::from_str(&format!(
"modrinth/theseus/{} (support@modrinth.com)",
@@ -213,18 +213,41 @@ pub async fn fetch_mirrors(
}
/// Using labrinth API, checks if an internet response can be found, with a timeout in seconds
#[tracing::instrument(skip(semaphore))]
#[tracing::instrument]
#[theseus_macros::debug_pin]
pub async fn check_internet(semaphore: &FetchSemaphore, timeout: u64) -> bool {
let result = fetch(
"https://api.modrinth.com",
None,
semaphore,
&CredentialsStore(None),
);
let result =
tokio::time::timeout(Duration::from_secs(timeout), result).await;
matches!(result, Ok(Ok(_)))
pub async fn check_internet(timeout: u64) -> bool {
REQWEST_CLIENT
.get("https://launcher-files.modrinth.com/detect.txt")
.timeout(Duration::from_secs(timeout))
.send()
.await
.is_ok()
}
/// Posts a JSON to a URL
#[tracing::instrument(skip(json_body, semaphore))]
#[theseus_macros::debug_pin]
pub async fn post_json<T>(
url: &str,
json_body: serde_json::Value,
semaphore: &FetchSemaphore,
credentials: &CredentialsStore,
) -> crate::Result<T>
where
T: DeserializeOwned,
{
let io_semaphore = semaphore.0.read().await;
let _permit = io_semaphore.acquire().await?;
let mut req = REQWEST_CLIENT.post(url).json(&json_body);
if let Some(creds) = &credentials.0 {
req = req.header("Authorization", &creds.session);
}
let result = req.send().await?.error_for_status()?;
let value = result.json().await?;
Ok(value)
}
pub async fn read_json<T>(

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_cli"
version = "0.4.0"
version = "0.5.2"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
edition = "2018"

View File

@@ -196,6 +196,7 @@ impl ProfileInit {
None,
None,
None,
None,
)
.await?;

View File

@@ -4,7 +4,6 @@ use eyre::Result;
use paris::*;
use tabled::Tabled;
use theseus::prelude::*;
use tokio::sync::oneshot;
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand, name = "user")]
@@ -41,18 +40,23 @@ impl UserAdd {
info!("Adding new user account to Theseus");
info!("A browser window will now open, follow the login flow there.");
let (tx, rx) = oneshot::channel::<url::Url>();
let flow = tokio::spawn(auth::authenticate(tx));
let login = auth::authenticate_begin_flow().await?;
let flow = tokio::spawn(auth::authenticate_await_complete_flow());
info!("Opening browser window at {}", login.verification_uri);
info!("Your code is {}", login.user_code);
let url = rx.await?;
match self.browser {
Some(browser) => webbrowser::open_browser(browser, url.as_str()),
None => webbrowser::open(url.as_str()),
Some(browser) => webbrowser::open_browser(
browser,
login.verification_uri.as_str(),
),
None => webbrowser::open(login.verification_uri.as_str()),
}?;
let credentials = flow.await??;
State::sync().await?;
success!("Logged in user {}.", credentials.0.username);
success!("Logged in user {}.", credentials.username);
Ok(())
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "theseus_gui",
"private": true,
"version": "0.4.0",
"version": "0.5.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -18,7 +18,7 @@
"floating-vue": "^2.0.0-beta.20",
"mixpanel-browser": "^2.47.0",
"ofetch": "^1.0.1",
"omorphia": "^0.4.34",
"omorphia": "^0.4.38",
"pinia": "^2.1.3",
"qrcode.vue": "^3.4.0",
"tauri-plugin-window-state-api": "github:tauri-apps/tauri-plugin-window-state#v1",

View File

@@ -21,8 +21,8 @@ dependencies:
specifier: ^1.0.1
version: 1.0.1
omorphia:
specifier: ^0.4.34
version: 0.4.34
specifier: ^0.4.38
version: 0.4.38
pinia:
specifier: ^2.1.3
version: 2.1.3(vue@3.3.4)
@@ -1348,8 +1348,8 @@ packages:
ufo: 1.1.2
dev: false
/omorphia@0.4.34:
resolution: {integrity: sha512-6uAH1kgzbYYmJDM41Vy4/MhzT9kRj+s1t8IknHKeOQqmVft+wPtv/pbA7pqTMfCzBOarLKKO5s4sNlz8TeMmaQ==}
/omorphia@0.4.38:
resolution: {integrity: sha512-V0vEarmAart6Gf5WuPUZ58TuIiQf7rI5HJpmYU7FVbtdvZ3q08VqyKZflCddbeBSFQ4/N+A+sNr/ELf/jz+Cug==}
dependencies:
dayjs: 1.11.7
floating-vue: 2.0.0-beta.20(vue@3.3.4)

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_gui"
version = "0.4.0"
version = "0.5.2"
description = "A Tauri App"
authors = ["you"]
license = ""

View File

@@ -1,6 +1,6 @@
use crate::api::Result;
use tauri::plugin::TauriPlugin;
use theseus::prelude::*;
use theseus::{hydra::init::DeviceLoginSuccess, prelude::*};
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("auth")
@@ -20,7 +20,7 @@ pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
/// Authenticate a user with Hydra - part 1
/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
#[tauri::command]
pub async fn auth_authenticate_begin_flow() -> Result<url::Url> {
pub async fn auth_authenticate_begin_flow() -> Result<DeviceLoginSuccess> {
Ok(auth::authenticate_begin_flow().await?)
}
@@ -28,8 +28,7 @@ pub async fn auth_authenticate_begin_flow() -> Result<url::Url> {
/// This completes the authentication flow quasi-synchronously, returning the sign-in credentials
/// (and also adding the credentials to the state)
#[tauri::command]
pub async fn auth_authenticate_await_completion(
) -> Result<(Credentials, Option<String>)> {
pub async fn auth_authenticate_await_completion() -> Result<Credentials> {
Ok(auth::authenticate_await_complete_flow().await?)
}

View File

@@ -24,7 +24,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_remove_project,
profile_update_managed_modrinth,
profile_repair_managed_modrinth,
profile_is_managed_modrinth,
profile_run,
profile_run_wait,
profile_run_credentials,
@@ -190,12 +189,6 @@ pub async fn profile_repair_managed_modrinth(
Ok(profile::update::repair_managed_modrinth(&path).await?)
}
// Gets if a profile is managed by Modrinth
#[tauri::command]
pub async fn profile_is_managed_modrinth(path: ProfilePathId) -> Result<bool> {
Ok(profile::is_managed_modrinth_pack(&path).await?)
}
// Exports a profile to a .mrpack file (export_location should end in .mrpack)
// invoke('profile_export_mrpack')
#[tauri::command]

View File

@@ -17,6 +17,7 @@ pub async fn profile_create(
modloader: ModLoader, // the modloader to use
loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
icon: Option<PathBuf>, // the icon for the profile
no_watch: Option<bool>,
) -> Result<ProfilePathId> {
let res = profile::create::profile_create(
name,
@@ -27,6 +28,7 @@ pub async fn profile_create(
None,
None,
None,
no_watch,
)
.await?;
Ok(res)

View File

@@ -142,7 +142,7 @@ fn main() {
.invoke_handler(tauri::generate_handler![
initialize_state,
is_dev,
toggle_decorations
toggle_decorations,
]);
builder

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Modrinth App",
"version": "0.4.0"
"version": "0.5.2"
},
"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; font-src https://cdn-raw.modrinth.com/fonts/inter/; img-src tauri: https: data: blob: 'unsafe-inline' asset: https://asset.localhost"
"csp": "default-src 'self'; connect-src https://modrinth.com https://*.modrinth.com https://mixpanel.com https://*.mixpanel.com https://*.cloudflare.com; 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 'self'; style-src unsafe-inline 'self'"
},
"updater": {
"active": true,
@@ -96,11 +96,11 @@
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"fullscreen": false,
"height": 650,
"height": 800,
"resizable": true,
"title": "Modrinth App",
"width": 1280,
"minHeight": 630,
"minHeight": 700,
"minWidth": 1100,
"visible": false,
"decorations": false

View File

@@ -5,11 +5,11 @@
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"fullscreen": false,
"height": 650,
"height": 800,
"resizable": true,
"title": "Modrinth App",
"width": 1280,
"minHeight": 630,
"minHeight": 700,
"minWidth": 1100,
"visible": false,
"decorations": true

View File

@@ -177,6 +177,20 @@ document.querySelector('body').addEventListener('click', function (e) {
}
})
document.querySelector('body').addEventListener('auxclick', function (e) {
// disables middle click -> new tab
if (e.button === 1) {
e.preventDefault()
// instead do a left click
const event = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
})
e.target.dispatchEvent(event)
}
})
const accounts = ref(null)
command_listener((e) => {
@@ -204,10 +218,11 @@ command_listener((e) => {
<AccountsCard ref="accounts" mode="small" />
</suspense>
<div class="pages-list">
<RouterLink to="/" class="btn icon-only collapsed-button">
<RouterLink v-tooltip="'Home'" to="/" class="btn icon-only collapsed-button">
<HomeIcon />
</RouterLink>
<RouterLink
v-tooltip="'Browse'"
to="/browse/modpack"
class="btn icon-only collapsed-button"
:class="{
@@ -216,7 +231,7 @@ command_listener((e) => {
>
<SearchIcon />
</RouterLink>
<RouterLink to="/library" class="btn icon-only collapsed-button">
<RouterLink v-tooltip="'Library'" to="/library" class="btn icon-only collapsed-button">
<LibraryIcon />
</RouterLink>
<Suspense>
@@ -226,6 +241,7 @@ command_listener((e) => {
</div>
<div class="settings pages-list">
<Button
v-tooltip="'Create profile'"
class="sleek-primary collapsed-button"
icon-only
:disabled="offline"
@@ -233,7 +249,7 @@ command_listener((e) => {
>
<PlusIcon />
</Button>
<RouterLink to="/settings" class="btn icon-only collapsed-button">
<RouterLink v-tooltip="'Settings'" to="/settings" class="btn icon-only collapsed-button">
<SettingsIcon />
</RouterLink>
</div>
@@ -246,7 +262,7 @@ command_listener((e) => {
</section>
<section class="mod-stats">
<Suspense>
<RunningAppBar data-tauri-drag-region />
<RunningAppBar />
</Suspense>
</section>
</div>
@@ -276,7 +292,7 @@ command_listener((e) => {
offset-height="var(--appbar-height)"
offset-width="var(--sidebar-width)"
/>
<RouterView v-slot="{ Component }" class="main-view">
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()">
<component :is="Component"></component>

View File

@@ -1,6 +1,5 @@
<svg
data-v-8c2610d6=""
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
viewBox="0 0 100 100"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2;"><circle cx="50" cy="50" r="50" style="fill:#fff;"

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bug"><rect width="8" height="14" x="8" y="6" rx="4"/><path d="m19 7-3 2"/><path d="m5 7 3 2"/><path d="m19 19-3-2"/><path d="m5 19 3-2"/><path d="M20 13h-4"/><path d="M4 13h4"/><path d="m10 4 1 2"/><path d="m14 4-1 2"/></svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -10,3 +10,5 @@ export { default as TextInputIcon } from './text-cursor-input.svg'
export { default as AddProjectImage } from './add-project.svg'
export { default as NewInstanceImage } from './new-instance.svg'
export { default as MenuIcon } from './menu.svg'
export { default as BugIcon } from './bug.svg'
export { default as ChatIcon } from './messages-square.svg'

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-messages-square"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2v5Z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@@ -128,3 +128,10 @@ input {
background-color: var(--color-raised-bg);
box-shadow: none !important;
}
img {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}

View File

@@ -200,6 +200,25 @@ const filteredResults = computed(() => {
return instanceMap.set('None', instances)
}
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A
if (sortBy.value === 'Name') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
// None should always be first
if (a[0] === 'None' && b[0] !== 'None') {
return -1
}
if (a[0] !== 'None' && b[0] === 'None') {
return 1
}
return a[0].localeCompare(b[0])
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
return instanceMap
})
</script>
@@ -226,6 +245,7 @@ const filteredResults = computed(() => {
<DropdownSelect
v-model="sortBy"
class="sort-dropdown"
name="Sort Dropdown"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..."
/>
@@ -235,6 +255,7 @@ const filteredResults = computed(() => {
<DropdownSelect
v-model="filters"
class="filter-dropdown"
name="Filter Dropdown"
:options="['All profiles', 'Custom instances', 'Downloaded modpacks']"
placeholder="Select..."
/>
@@ -244,6 +265,7 @@ const filteredResults = computed(() => {
<DropdownSelect
v-model="group"
class="group-dropdown"
name="Group Dropdown"
:options="['Category', 'Loader', 'Game version', 'None']"
placeholder="Select..."
/>
@@ -265,7 +287,7 @@ const filteredResults = computed(() => {
<Instance
v-for="(instance, index) in instanceSection.value"
ref="instanceComponents"
:key="instance.id"
:key="instance.path"
:instance="instance"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instanceComponents[index])"
/>

View File

@@ -2,6 +2,7 @@
<div
v-if="mode !== 'isolated'"
ref="button"
v-tooltip="'Minecraft accounts'"
class="button-base avatar-button"
:class="{ expanded: mode === 'expanded' }"
@click="showCard = !showCard"
@@ -11,18 +12,9 @@
:src="
selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://cdn.discordapp.com/attachments/817413688771608587/1129829843425570867/unnamed.png'
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div v-show="mode === 'expanded'" class="avatar-text">
<div class="text no-select">
{{ selectedAccount ? selectedAccount.username : 'Offline' }}
</div>
<p class="accounts-text no-select">
<UsersIcon />
Accounts
</p>
</div>
</div>
<transition name="fade">
<Card
@@ -64,10 +56,55 @@
</Button>
</Card>
</transition>
<Modal ref="loginModal" class="modal" header="Signing in">
<div class="modal-body">
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
<div class="modal-text">
<div class="label">Copy this code</div>
<div class="code-text">
<div class="code">
{{ loginCode }}
</div>
<Button
v-tooltip="'Copy code'"
icon-only
large
color="raised"
@click="() => clipboardWrite(loginCode)"
>
<ClipboardCopyIcon />
</Button>
</div>
<div>And enter it on Microsoft's website to sign in.</div>
<div class="iconified-input">
<LogInIcon />
<input type="text" :value="loginUrl" readonly />
<Button
v-tooltip="'Open link'"
icon-only
color="raised"
@click="() => clipboardWrite(loginUrl)"
>
<GlobeIcon />
</Button>
</div>
</div>
</div>
</Modal>
</template>
<script setup>
import { Avatar, Button, Card, PlusIcon, TrashIcon, UsersIcon, LogInIcon } from 'omorphia'
import {
Avatar,
Button,
Card,
PlusIcon,
TrashIcon,
LogInIcon,
Modal,
GlobeIcon,
ClipboardCopyIcon,
} from 'omorphia'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import {
users,
@@ -76,10 +113,9 @@ import {
authenticate_await_completion,
} from '@/helpers/auth'
import { get, set } from '@/helpers/settings'
import { WebviewWindow } from '@tauri-apps/api/window'
import { handleError } from '@/store/state.js'
import { get as getCreds, login_minecraft } from '@/helpers/mr_auth'
import { mixpanel_track } from '@/helpers/mixpanel'
import QrcodeVue from 'qrcode.vue'
defineProps({
mode: {
@@ -91,8 +127,13 @@ defineProps({
const emit = defineEmits(['change'])
const loginCode = ref(null)
const settings = ref({})
const accounts = ref([])
const loginUrl = ref('')
const loginModal = ref(null)
async function refreshValues() {
settings.value = await get().catch(handleError)
accounts.value = await users().catch(handleError)
@@ -116,30 +157,33 @@ async function setAccount(account) {
emit('change')
}
async function login() {
const url = await authenticate_begin_flow().catch(handleError)
const clipboardWrite = async (a) => {
navigator.clipboard.writeText(a)
}
const window = new WebviewWindow('loginWindow', {
title: 'Modrinth App',
url: url,
async function login() {
const loginSuccess = await authenticate_begin_flow().catch(handleError)
loginModal.value.show()
loginCode.value = loginSuccess.user_code
loginUrl.value = loginSuccess.verification_uri
await window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: loginSuccess.verification_uri,
},
})
const loggedIn = await authenticate_await_completion().catch(handleError)
loginModal.value.hide()
if (loggedIn && loggedIn[0]) {
await setAccount(loggedIn[0])
if (loggedIn) {
await setAccount(loggedIn)
await refreshValues()
const creds = await getCreds().catch(handleError)
if (!creds) {
try {
await login_minecraft(loggedIn[1])
} catch (err) {
/* empty */
}
}
}
await window.close()
loginModal.value.hide()
mixpanel_track('AccountLogIn')
}
@@ -212,7 +256,7 @@ onBeforeUnmount(() => {
flex-direction: column;
top: 0.5rem;
left: 5.5rem;
z-index: 9;
z-index: 11;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--color-button-bg);
@@ -220,6 +264,18 @@ onBeforeUnmount(() => {
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
max-height: 98vh;
overflow-y: auto;
&::-webkit-scrollbar-track {
border-top-right-radius: 1rem;
border-bottom-right-radius: 1rem;
}
&::-webkit-scrollbar {
border-top-right-radius: 1rem;
border-bottom-right-radius: 1rem;
}
&.hidden {
display: none;
@@ -317,4 +373,75 @@ onBeforeUnmount(() => {
gap: 0.25rem;
margin: 0;
}
.qr-code {
background-color: white !important;
border-radius: var(--radius-md);
}
.modal-body {
display: flex;
flex-direction: row;
gap: var(--gap-lg);
align-items: center;
padding: var(--gap-xl);
.modal-text {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
width: 100%;
h2,
p {
margin: 0;
}
.code-text {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
align-items: center;
.code {
background-color: var(--color-bg);
border-radius: var(--radius-md);
border: solid 1px var(--color-button-bg);
font-family: var(--mono-font);
letter-spacing: var(--gap-md);
color: var(--color-contrast);
font-size: 2rem;
font-weight: bold;
padding: var(--gap-sm) 0 var(--gap-sm) var(--gap-md);
}
.btn {
width: 2.5rem;
height: 2.5rem;
}
}
}
}
.button-row {
display: flex;
flex-direction: row;
}
.modal {
position: absolute;
}
.code {
color: var(--color-brand);
padding: 0.05rem 0.1rem;
// row not column
display: flex;
.card {
background: var(--color-base);
color: var(--color-contrast);
padding: 0.5rem 1rem;
}
}
</style>

View File

@@ -1,5 +1,11 @@
<template>
<div class="breadcrumbs">
<Button class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<ChevronLeftIcon />
</Button>
<Button class="breadcrumbs__forward transparent" icon-only @click="$router.forward()">
<ChevronRightIcon />
</Button>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<div v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name" class="breadcrumbs__item">
<router-link
@@ -25,7 +31,7 @@
</template>
<script setup>
import { ChevronRightIcon } from 'omorphia'
import { ChevronRightIcon, Button, ChevronLeftIcon } from 'omorphia'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useRoute } from 'vue-router'
import { computed } from 'vue'
@@ -60,6 +66,18 @@ const breadcrumbs = computed(() => {
margin: auto 0;
}
}
.breadcrumbs__back,
.breadcrumbs__forward {
margin: auto 0;
color: var(--color-base);
height: unset;
width: unset;
}
.breadcrumbs__forward {
margin-right: 1rem;
}
}
.selected {

View File

@@ -188,6 +188,8 @@ const exportPack = async () => {
}
.select-checkbox {
gap: var(--gap-sm);
button.checkbox {
border: none;
}

View File

@@ -25,6 +25,7 @@
v-model="selectedVersion"
:options="versions"
placeholder="Select version"
name="Version select"
:display-name="
(version) =>
`${version?.name} (${version?.loaders
@@ -165,5 +166,9 @@ td:first-child {
flex-direction: column;
gap: 1rem;
padding: 1rem;
:deep(.animated-dropdown .options) {
max-height: 13.375rem;
}
}
</style>

View File

@@ -181,6 +181,10 @@
: 'Select profiles to import'
}}
</Button>
<ProgressBar
v-if="loading"
:progress="(importedProfiles / (totalProfiles + 0.0001)) * 100"
/>
</div>
</div>
</Modal>
@@ -224,6 +228,7 @@ import {
get_importable_instances,
import_instance,
} from '@/helpers/import.js'
import ProgressBar from '@/components/ui/ProgressBar.vue'
const themeStore = useTheming()
@@ -420,6 +425,8 @@ const profiles = ref(
)
const loading = ref(false)
const importedProfiles = ref(0)
const totalProfiles = ref(0)
const selectedProfileType = ref('MultiMC')
const profileOptions = ref([
@@ -480,6 +487,10 @@ const setPath = () => {
}
const next = async () => {
importedProfiles.value = 0
totalProfiles.value = Array.from(profiles.value.values())
.map((profiles) => profiles.filter((profile) => profile.selected).length)
.reduce((a, b) => a + b, 0)
loading.value = true
for (const launcher of Array.from(profiles.value.entries()).map(([launcher, profiles]) => ({
launcher,
@@ -491,6 +502,7 @@ const next = async () => {
.catch(handleError)
.then(() => console.log(`Successfully Imported ${profile.name} from ${launcher.launcher}`))
profile.selected = false
importedProfiles.value++
}
}
loading.value = false
@@ -628,6 +640,7 @@ const next = async () => {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--gap-md);
.transparent {

View File

@@ -183,7 +183,7 @@ const createInstance = async () => {
await router.push(`/instance/${encodeURIComponent(id)}/`)
const instance = await get(id, true)
await installVersionDependencies(instance, versions.value)
await installVersionDependencies(instance, versions.value[0])
mixpanel_track('InstanceCreate', {
profile_name: name.value,
@@ -204,7 +204,7 @@ const createInstance = async () => {
source: 'ProjectInstallModal',
})
installModal.value.hide()
if (installModal.value) installModal.value.hide()
creatingInstance.value = false
}

View File

@@ -119,7 +119,7 @@ const install = async (e) => {
'background-image': `url(${
project.featured_gallery ??
project.gallery[0] ??
'https://cdn.discordapp.com/attachments/817413688771608587/1119143634319724564/image.png'
'https://launcher-files.modrinth.com/assets/maze-bg.png'
})`,
'no-image': !project.featured_gallery && !project.gallery[0],
}"

View File

@@ -1,5 +1,9 @@
<template>
<div class="action-groups">
<a href="https://discord.gg/modrinth" class="link">
<ChatIcon />
<span> Get support </span>
</a>
<Button
v-if="currentLoadingBars.length > 0"
ref="infoButton"
@@ -120,6 +124,7 @@ import { refreshOffline, isOffline } from '@/helpers/utils.js'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { handleError } from '@/store/notifications.js'
import { mixpanel_track } from '@/helpers/mixpanel'
import { ChatIcon } from '@/assets/icons'
const router = useRouter()
const card = ref(null)
@@ -266,7 +271,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
gap: var(--gap-md);
}
.arrow {
@@ -452,4 +457,14 @@ onBeforeUnmount(() => {
transform: translateY(-100%);
}
}
.link {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
margin: 0;
color: var(--color-text);
text-decoration: none;
}
</style>

View File

@@ -1,17 +1,12 @@
<template>
<div ref="button" class="button-base avatar-button" :class="{ highlighted: showDemo }">
<Avatar
src="https://cdn.discordapp.com/attachments/817413688771608587/1129829843425570867/unnamed.png"
/>
<Avatar src="https://launcher-files.modrinth.com/assets/steve_head.png" />
</div>
<transition name="fade">
<div v-if="showDemo" class="card-section">
<Card ref="card" class="fake-account-card expanded highlighted">
<div class="selected account">
<Avatar
size="xs"
src="https://cdn.discordapp.com/attachments/817413688771608587/1129829843425570867/unnamed.png"
/>
<Avatar size="xs" src="https://launcher-files.modrinth.com/assets/steve_head.png" />
<div>
<h4>Modrinth</h4>
<p>Selected</p>

View File

@@ -1,5 +1,9 @@
<template>
<div class="action-groups">
<Button color="danger" outline @click="exit">
<LogOutIcon />
Exit tutorial
</Button>
<Button v-if="showDownload" ref="infoButton" icon-only class="icon-button show-card-icon">
<DownloadIcon />
</Button>
@@ -36,7 +40,14 @@
</template>
<script setup>
import { Button, DownloadIcon, Card, StopCircleIcon, TerminalSquareIcon } from 'omorphia'
import {
Button,
DownloadIcon,
Card,
StopCircleIcon,
TerminalSquareIcon,
LogOutIcon,
} from 'omorphia'
import ProgressBar from '@/components/ui/ProgressBar.vue'
defineProps({
@@ -48,6 +59,10 @@ defineProps({
type: Boolean,
default: false,
},
exit: {
type: Function,
required: true,
},
})
</script>

View File

@@ -32,6 +32,7 @@ defineProps({
<DropdownSelect
v-model="sortBy"
class="sort-dropdown"
name="Sort Dropdown"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..."
/>
@@ -41,6 +42,7 @@ defineProps({
<DropdownSelect
v-model="filters"
class="filter-dropdown"
name="Filter Dropdown"
:options="['All profiles', 'Custom instances', 'Downloaded modpacks']"
placeholder="Select..."
/>
@@ -50,6 +52,7 @@ defineProps({
<DropdownSelect
v-model="group"
class="group-dropdown"
name="Group dropdown"
:options="['Category', 'Loader', 'Game version', 'None']"
placeholder="Select..."
/>
@@ -65,7 +68,7 @@ defineProps({
>
<Avatar
size="sm"
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
alt="Mod card"
class="mod-image"
/>

View File

@@ -63,7 +63,7 @@ defineProps({
>
<Avatar
size="sm"
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
alt="Mod card"
class="mod-image"
/>
@@ -79,7 +79,7 @@ defineProps({
<div
class="banner no-image"
:style="{
'background-image': `url(https://cdn.discordapp.com/attachments/817413688771608587/1119143634319724564/image.png)`,
'background-image': `url(https://launcher-files.modrinth.com/assets/maze-bg.png)`,
}"
>
<div class="badges">
@@ -111,7 +111,7 @@ defineProps({
<Avatar
class="icon"
size="sm"
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
/>
<div class="title">
<div class="title-text">Example Project</div>

View File

@@ -176,7 +176,7 @@ defineProps({
</Card>
</aside>
<div ref="searchWrapper" class="search">
<Promotion class="promotion" />
<Promotion class="promotion" query-param="?r=launcher" />
<Card class="project-type-container">
<NavRow :links="selectableProjectTypes" />
</Card>
@@ -220,7 +220,7 @@ defineProps({
<Card v-for="project in 20" :key="project" class="search-card button-base">
<div class="icon">
<Avatar
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
size="md"
class="search-icon"
/>

View File

@@ -9,6 +9,8 @@ import QrcodeVue from 'qrcode.vue'
const loginUrl = ref(null)
const loginModal = ref()
const loginCode = ref(null)
const finalizedLogin = ref(false)
const props = defineProps({
nextPage: {
@@ -22,25 +24,28 @@ const props = defineProps({
})
async function login() {
const url = await authenticate_begin_flow().catch(handleError)
loginUrl.value = url
const loginSuccess = await authenticate_begin_flow().catch(handleError)
loginUrl.value = loginSuccess.verification_uri
loginCode.value = loginSuccess.user_code
loginModal.value.show()
await window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: url,
path: loginSuccess.verification_uri,
},
})
const loggedIn = await authenticate_await_completion().catch(handleError)
loginModal.value.hide()
props.nextPage(loggedIn[1])
const settings = await get().catch(handleError)
settings.default_user = loggedIn[0].id
settings.default_user = loggedIn.id
await set(settings).catch(handleError)
finalizedLogin.value = true
await mixpanel.track('AccountLogIn')
props.nextPage()
}
const openUrl = async () => {
@@ -52,12 +57,16 @@ const openUrl = async () => {
},
})
}
const clipboardWrite = async (a) => {
navigator.clipboard.writeText(a)
}
</script>
<template>
<div class="login-card">
<img
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
class="logo"
alt="Minecraft art"
/>
@@ -80,9 +89,6 @@ const openUrl = async () => {
<LogInIcon v-if="!finalizedLogin" />
{{ finalizedLogin ? 'Next' : 'Sign in' }}
</Button>
<Button v-if="loginUrl" class="transparent" @click="loginModal.show()">
Browser didn't open?
</Button>
</div>
<Button class="transparent" large @click="nextPage()"> Next </Button>
</div>
@@ -92,28 +98,28 @@ const openUrl = async () => {
<div class="modal-body">
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
<div class="modal-text">
<p>
Sign into Microsoft with your browser. If your browser didn't open, you can copy and open
the link below, or scan the QR code with your device.
</p>
<div class="iconified-input">
<LogInIcon />
<input type="text" :value="loginUrl" readonly />
<div class="label">Copy this code</div>
<div class="code-text">
<div class="code">
{{ loginCode }}
</div>
<Button
v-tooltip="'Copy link'"
v-tooltip="'Copy code'"
icon-only
large
color="raised"
@click="() => navigator.clipboard.writeText(loginUrl)"
@click="() => clipboardWrite(loginCode)"
>
<ClipboardCopyIcon />
</Button>
</div>
<div class="button-row">
<Button @click="openUrl">
<div>And enter it on Microsoft's website to sign in.</div>
<div class="iconified-input">
<LogInIcon />
<input type="text" :value="loginUrl" readonly />
<Button v-tooltip="'Open link'" icon-only color="raised" @click="openUrl">
<GlobeIcon />
Open link
</Button>
<Button class="transparent" @click="loginModal.hide"> Cancel </Button>
</div>
</div>
</div>
@@ -196,6 +202,7 @@ const openUrl = async () => {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
width: 100%;
h2,
p {
@@ -204,6 +211,30 @@ const openUrl = async () => {
}
}
.code-text {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
align-items: center;
.code {
background-color: var(--color-bg);
border-radius: var(--radius-md);
border: solid 1px var(--color-button-bg);
font-family: var(--mono-font);
letter-spacing: var(--gap-md);
color: var(--color-contrast);
font-size: 2rem;
font-weight: bold;
padding: var(--gap-sm) 0 var(--gap-sm) var(--gap-md);
}
.btn {
width: 2.5rem;
height: 2.5rem;
}
}
.sticker {
width: 100%;
max-width: 25rem;
@@ -217,4 +248,17 @@ const openUrl = async () => {
gap: var(--gap-sm);
align-items: center;
}
.code {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
.card {
background: var(--color-base);
color: var(--color-contrast);
padding: 0.5rem 1rem;
margin-top: 0.5rem;
}
}
</style>

View File

@@ -211,7 +211,7 @@ onMounted(() => {
<div class="link-row">
<a v-if="loggingIn" class="button-base" @click="loggingIn = false"> Create account </a>
<a v-else class="button-base" @click="loggingIn = true">Sign in</a>
<a class="button-base" href="https://staging.modrinth.com/auth/reset-password">
<a class="button-base" href="https://modrinth.com/auth/reset-password">
Forgot password?
</a>
</div>

View File

@@ -8,7 +8,6 @@ import {
SettingsIcon,
XIcon,
Notifications,
LogOutIcon,
} from 'omorphia'
import { appWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from 'tauri-plugin-window-state-api'
@@ -83,15 +82,18 @@ const finishOnboarding = async () => {
async function fetchSettings() {
const fetchSettings = await get().catch(handleError)
if (!fetchSettings.java_globals) {
fetchSettings.java_globals = {}
}
if (!fetchSettings.java_globals.JAVA_17) {
const path = await auto_install_java(17).catch(handleError)
fetchSettings.java_globals.JAVA_17 = await get_jre(path).catch(handleError)
const path1 = await auto_install_java(17).catch(handleError)
fetchSettings.java_globals.JAVA_17 = await get_jre(path1).catch(handleError)
}
if (!fetchSettings.java_globals.JAVA_8) {
const path = await auto_install_java(8).catch(handleError)
fetchSettings.java_globals.JAVA_8 = await get_jre(path).catch(handleError)
const path2 = await auto_install_java(8).catch(handleError)
fetchSettings.java_globals.JAVA_8 = await get_jre(path2).catch(handleError)
}
await set(fetchSettings).catch(handleError)
@@ -157,7 +159,10 @@ onMounted(async () => {
<div class="btn icon-only" :class="{ active: phase < 4 }">
<HomeIcon />
</div>
<div class="btn icon-only" :class="{ active: phase === 4 || phase === 5 }">
<div
class="btn icon-only"
:class="{ active: phase === 4 || phase === 5, highlighted: phase === 4 }"
>
<SearchIcon />
</div>
<div
@@ -172,9 +177,6 @@ onMounted(async () => {
</div>
</div>
<div class="settings pages-list">
<Button class="active" icon-only @click="finishOnboarding">
<LogOutIcon />
</Button>
<Button class="sleek-primary" icon-only>
<PlusIcon />
</Button>
@@ -189,7 +191,11 @@ onMounted(async () => {
<Breadcrumbs data-tauri-drag-region />
</section>
<section class="mod-stats">
<FakeAppBar :show-running="phase === 7" :show-download="phase === 5">
<FakeAppBar
:show-running="phase === 7"
:show-download="phase === 5"
:exit="finishOnboarding"
>
<template #running>
<TutorialTip
:progress-function="nextPhase"
@@ -427,12 +433,6 @@ onMounted(async () => {
background-color: var(--color-brand-highlight);
transition: all ease-in-out 0.1s;
}
&.sleek-exit {
background-color: var(--color-red);
color: var(--color-accent-contrast);
transition: all ease-in-out 0.1s;
}
}
}

View File

@@ -15,7 +15,9 @@ import { invoke } from '@tauri-apps/api/tauri'
/// Authenticate a user with Hydra - part 1
/// This begins the authentication flow quasi-synchronously
/// This returns a URL to be opened in a browser
/// This returns a DeviceLoginSuccess object, with two relevant fields:
/// - verification_uri: the URL to go to to complete the flow
/// - user_code: the code to enter on the verification_uri page
export async function authenticate_begin_flow() {
return await invoke('plugin:auth|auth_authenticate_begin_flow')
}

View File

@@ -2,7 +2,7 @@ import { ofetch } from 'ofetch'
import { handleError } from '@/store/state.js'
import { getVersion } from '@tauri-apps/api/app'
export const useFetch = async (url, item) => {
export const useFetch = async (url, item, isSilent) => {
try {
const version = await getVersion()
@@ -10,7 +10,9 @@ export const useFetch = async (url, item) => {
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
})
} catch (err) {
handleError({ message: `Error fetching ${item}` })
if (!isSilent) {
handleError({ message: `Error fetching ${item}` })
}
console.error(err)
}
}

View File

@@ -34,7 +34,9 @@ export async function get_importable_instances(launcherType, basePath) {
/// eg: import_instance("profile-name-to-go-to", "MultiMC", "C:/MultiMC", "Instance 1")
export async function import_instance(launcherType, basePath, instanceFolder) {
// create a basic, empty instance (most properties will be filled in by the import process)
const profilePath = await create(instanceFolder, '1.19.4', 'vanilla', 'latest', null)
// We do NOT watch the fs for changes to avoid duplicate events during installation
// fs watching will be enabled once the instance is imported
const profilePath = await create(instanceFolder, '1.19.4', 'vanilla', 'latest', null, true)
return await invoke('plugin:import|import_import_instance', {
profilePath,

View File

@@ -9,17 +9,23 @@ export async function get_game_versions() {
// Gets the fabric versions from daedalus
// Returns Manifest
export async function get_fabric_versions() {
return await invoke('plugin:metadata|metadata_get_fabric_versions')
const c = await invoke('plugin:metadata|metadata_get_fabric_versions')
console.log('Getting fabric versions', c)
return c
}
// Gets the forge versions from daedalus
// Returns Manifest
export async function get_forge_versions() {
return await invoke('plugin:metadata|metadata_get_forge_versions')
const c = await invoke('plugin:metadata|metadata_get_forge_versions')
console.log('Getting forge versions', c)
return c
}
// Gets the quilt versions from daedalus
// Returns Manifest
export async function get_quilt_versions() {
return await invoke('plugin:metadata|metadata_get_quilt_versions')
const c = await invoke('plugin:metadata|metadata_get_quilt_versions')
console.log('Getting quilt versions', c)
return c
}

View File

@@ -16,13 +16,14 @@ import { invoke } from '@tauri-apps/api/tauri'
- icon is a path to an image file, which will be copied into the profile directory
*/
export async function create(name, gameVersion, modloader, loaderVersion, icon) {
export async function create(name, gameVersion, modloader, loaderVersion, icon, noWatch) {
return await invoke('plugin:profile_create|profile_create', {
name,
gameVersion,
modloader,
loaderVersion,
icon,
noWatch,
})
}
@@ -110,11 +111,6 @@ export async function update_repair_modrinth(path) {
return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
}
// Gets whether a profile is managed by Modrinth
export async function is_managed_modrinth(path) {
return await invoke('plugin:profile|profile_is_managed_modrinth', { path })
}
// Export a profile to .mrpack
/// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
// Version id is optional (ie: 1.1.5)

View File

@@ -29,7 +29,7 @@ import ModInstallModal from '@/components/ui/ModInstallModal.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import IncompatibilityWarningModal from '@/components/ui/IncompatibilityWarningModal.vue'
import { useFetch } from '@/helpers/fetch.js'
import { check_installed, get as getInstance } from '@/helpers/profile.js'
import { check_installed, get, get as getInstance } from '@/helpers/profile.js'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import { isOffline } from '@/helpers/utils'
import { offline_listener } from '@/helpers/events'
@@ -56,6 +56,7 @@ const orFacets = ref([])
const selectedVersions = ref([])
const onlyOpenSource = ref(false)
const showSnapshots = ref(false)
const hideAlreadyInstalled = ref(false)
const selectedEnvironments = ref([])
const sortTypes = readonly([
{ display: 'Relevance', name: 'relevance' },
@@ -143,6 +144,9 @@ if (route.query.m) {
if (route.query.o) {
currentPage.value = Math.ceil(route.query.o / maxResults.value) + 1
}
if (route.query.ai) {
hideAlreadyInstalled.value = route.query.ai === 'true'
}
async function refreshSearch() {
const base = 'https://api.modrinth.com/v2/'
@@ -222,6 +226,16 @@ async function refreshSearch() {
])
}
if (hideAlreadyInstalled.value) {
const installedMods = await get(instanceContext.value.path, false).then((x) =>
Object.values(x.projects)
.filter((x) => x.metadata.project)
.map((x) => x.metadata.project.id)
)
installedMods.map((x) => [`project_id != ${x}`]).forEach((x) => formattedFacets.push(x))
console.log(`facets=${JSON.stringify(formattedFacets)}`)
}
params.push(`facets=${JSON.stringify(formattedFacets)}`)
}
const offset = (currentPage.value - 1) * maxResults.value
@@ -237,9 +251,16 @@ async function refreshSearch() {
let val = `${base}${url}`
const rawResults = await useFetch(val, 'search results')
let rawResults = await useFetch(val, 'search results', offline.value)
if (!rawResults) {
rawResults = {
hits: [],
total_hits: 0,
limit: 1,
}
}
if (instanceContext.value) {
for (let val of rawResults.hits) {
for (val of rawResults.hits) {
val.installed = await check_installed(instanceContext.value.path, val.project_id).then(
(x) => (val.installed = x)
)
@@ -255,7 +276,6 @@ async function onSearchChange(newPageNumber) {
return
}
await refreshSearch()
const obj = getSearchUrl((currentPage.value - 1) * maxResults.value, true)
// Only replace in router if the query is different
@@ -333,6 +353,10 @@ function getSearchUrl(offset, useObj) {
queryItems.push('il=true')
obj.il = true
}
if (hideAlreadyInstalled.value) {
queryItems.push('ai=true')
obj.ai = true
}
let url = `${route.path}`
@@ -510,7 +534,7 @@ onUnmounted(() => unlistenOffline())
</script>
<template>
<div v-if="!offline" ref="searchWrapper" class="search-container">
<div ref="searchWrapper" class="search-container">
<aside class="filter-panel">
<Card v-if="instanceContext" class="small-instance">
<router-link :to="`/instance/${encodeURIComponent(instanceContext.path)}`" class="instance">
@@ -549,6 +573,13 @@ onUnmounted(() => unlistenOffline())
@update:model-value="onSearchChangeToTop(1)"
@click.prevent.stop
/>
<Checkbox
v-model="hideAlreadyInstalled"
label="Hide already installed"
class="filter-checkbox"
@update:model-value="onSearchChangeToTop(1)"
@click.prevent.stop
/>
</Card>
<Card class="search-panel-card">
<Button
@@ -656,7 +687,7 @@ onUnmounted(() => unlistenOffline())
</Card>
</aside>
<div class="search">
<Promotion class="promotion" :external="false" />
<Promotion class="promotion" :external="false" query-param="?r=launcher" />
<Card class="project-type-container">
<NavRow :links="selectableProjectTypes" />
</Card>
@@ -704,7 +735,10 @@ onUnmounted(() => unlistenOffline())
class="pagination-before"
@switch-page="onSearchChange"
/>
<SplashScreen v-if="loading || offline" />
<SplashScreen v-if="loading" />
<section v-else-if="offline && results.total_hits === 0" class="offline">
You are currently offline. Connect to the internet to browse Modrinth!
</section>
<section v-else class="project-list display-mode--list instance-results" role="list">
<SearchCard
v-for="result in results.hits"
@@ -890,6 +924,11 @@ onUnmounted(() => unlistenOffline())
margin: 0 1rem 0.5rem 20.5rem;
width: calc(100% - 20.5rem);
.offline {
margin: 1rem;
text-align: center;
}
.loading {
margin: 2rem;
text-align: center;

View File

@@ -41,23 +41,31 @@ 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}`,
'featured modpacks'
'featured modpacks',
offline.value
)
if (response) featuredModpacks.value = response.hits
if (response) {
featuredModpacks.value = response.hits
} else {
featuredModpacks.value = []
}
}
const getFeaturedMods = async () => {
const response = await useFetch(
'https://api.modrinth.com/v2/search?facets=[["project_type:mod"]]&limit=10&index=follows',
'featured mods'
'featured mods',
offline.value
)
if (response) featuredMods.value = response.hits
if (response) {
featuredMods.value = response.hits
} else {
featuredModpacks.value = []
}
}
await getInstances()
if (!offline.value) {
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
}
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
const unlistenProfile = await profile_listener(async (e) => {
await getInstances()

View File

@@ -35,12 +35,7 @@ onUnmounted(() => {
</script>
<template>
<GridDisplay
v-if="instances.length > 0"
label="Instances"
:instances="instances"
class="display"
/>
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
<div v-else class="no-instance">
<div class="icon">
<NewInstanceImage />
@@ -55,11 +50,6 @@ onUnmounted(() => {
</template>
<style lang="scss" scoped>
.display {
background-color: rgb(30, 31, 34);
min-height: 100%;
}
.no-instance {
display: flex;
flex-direction: column;

View File

@@ -1,13 +1,26 @@
<script setup>
import { ref, watch } from 'vue'
import { Card, Slider, DropdownSelect, Toggle, Modal, LogOutIcon, LogInIcon } from 'omorphia'
import {
Card,
Slider,
DropdownSelect,
Toggle,
Modal,
LogOutIcon,
LogInIcon,
Button,
BoxIcon,
FolderSearchIcon,
UpdatedIcon,
} from 'omorphia'
import { handleError, useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings'
import { 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'
import ModrinthLoginScreen from '@/components/ui/tutorial/ModrinthLoginScreen.vue'
import { mixpanel_opt_out_tracking, mixpanel_opt_in_tracking } from '@/helpers/mixpanel'
import { open } from '@tauri-apps/api/dialog'
const pageOptions = ['Home', 'Library']
@@ -24,6 +37,7 @@ fetchSettings.javaArgs = fetchSettings.custom_java_args.join(' ')
fetchSettings.envArgs = fetchSettings.custom_env_args.map((x) => x.join('=')).join(' ')
const settings = ref(fetchSettings)
const settingsDir = ref(settings.value.loaded_config_dir)
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
watch(
@@ -91,6 +105,19 @@ async function signInAfter() {
loginScreenModal.value.hide()
credentials.value = await getCreds().catch(handleError)
}
async function findLauncherDir() {
const newDir = await open({ multiple: false, directory: true })
if (newDir) {
settingsDir.value = newDir
await refreshDir()
}
}
async function refreshDir() {
await change_config_dir(settingsDir.value)
}
</script>
<template>
@@ -98,7 +125,7 @@ async function signInAfter() {
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Account</span>
<span class="label__title size-card-header">General settings</span>
</h3>
</div>
<Modal
@@ -125,6 +152,25 @@ async function signInAfter() {
<LogInIcon /> Sign in
</button>
</div>
<label for="theme">
<span class="label__title">App directory</span>
<span class="label__description">
The directory where the launcher stores all of its files.
</span>
</label>
<div class="app-directory">
<div class="iconified-input">
<BoxIcon />
<input id="appDir" v-model="settingsDir" type="text" class="input" />
<Button @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</div>
<Button large @click="refreshDir">
<UpdatedIcon />
Refresh
</Button>
</div>
</Card>
<Card>
<div class="label">
@@ -262,10 +308,20 @@ async function signInAfter() {
<span class="label__title">Disable analytics</span>
<span class="label__description">
Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. Opting out will disable this data collection.
customize your experience. By enabling this option, you opt out and your data will no
longer be collected.
</span>
</label>
<Toggle id="opt-out-analytics" v-model="settings.opt_out_analytics" />
<Toggle
id="opt-out-analytics"
:model-value="settings.opt_out_analytics"
:checked="settings.opt_out_analytics"
@update:model-value="
(e) => {
settings.opt_out_analytics = e
}
"
/>
</div>
</Card>
<Card>
@@ -379,7 +435,7 @@ async function signInAfter() {
<label for="fullscreen">
<span class="label__title">Fullscreen</span>
<span class="label__description">
Overwrites the option.txt file to start in full screen when launched.
Overwrites the options.txt file to start in full screen when launched.
</span>
</label>
<Toggle
@@ -455,4 +511,19 @@ async function signInAfter() {
}
}
}
.app-directory {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
.iconified-input {
flex-grow: 1;
input {
flex-basis: auto;
}
}
}
</style>

View File

@@ -72,17 +72,10 @@
Options
</RouterLink>
</div>
<hr class="card-divider" />
<div class="pages-list">
<Button class="transparent" @click="exportModal.show()">
<PackageIcon />
Export modpack
</Button>
</div>
</Card>
</div>
<div class="content">
<Promotion />
<Promotion query-param="?r=launcher" />
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loadingBar.startLoading()" @resolve="loadingBar.stopLoading()">
@@ -118,7 +111,6 @@
>
<template #filter_update><UpdatedIcon />Select Updatable</template>
</ContextMenu>
<ExportModal ref="exportModal" :instance="instance" />
</template>
<script setup>
import {
@@ -156,15 +148,12 @@ import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
import { isOffline, showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { mixpanel_track } from '@/helpers/mixpanel'
import { PackageIcon } from '@/assets/icons/index.js'
import ExportModal from '@/components/ui/ExportModal.vue'
import { convertFileSrc } from '@tauri-apps/api/tauri'
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const exportModal = ref(null)
const instance = ref(await get(route.params.id).catch(handleError))

View File

@@ -185,12 +185,21 @@ interval.value = setInterval(async () => {
if (logs.value.length > 0) {
logs.value[0] = await getLiveLog()
if (selectedLogIndex.value === 0 && !userScrolled.value) {
await nextTick()
isAutoScrolling.value = true
logContainer.value.scrollTop =
logContainer.value.scrollHeight - logContainer.value.offsetHeight
setTimeout(() => (isAutoScrolling.value = false), 50)
// Allow resetting of userScrolled if the user scrolls to the bottom
if (selectedLogIndex.value === 0) {
if (
logContainer.value.scrollTop + logContainer.value.offsetHeight >=
logContainer.value.scrollHeight - 10
)
userScrolled.value = false
if (!userScrolled.value) {
await nextTick()
isAutoScrolling.value = true
logContainer.value.scrollTop =
logContainer.value.scrollHeight - logContainer.value.offsetHeight
setTimeout(() => (isAutoScrolling.value = false), 50)
}
}
}
}, 250)

View File

@@ -1,13 +1,13 @@
<template>
<Card v-if="projects.length > 0" class="mod-card">
<div class="second-row">
<Chips
v-if="Object.keys(selectableProjectTypes).length > 1"
<div class="dropdown-input">
<DropdownSelect
v-model="selectedProjectType"
:items="Object.keys(selectableProjectTypes)"
:options="Object.keys(selectableProjectTypes)"
default-value="All"
name="project-type-dropdown"
color="primary"
/>
</div>
<div class="card-row">
<div class="iconified-input">
<SearchIcon />
<input
@@ -24,215 +24,253 @@
<XIcon />
</Button>
</div>
<span class="manage">
<DropdownButton
:options="['search', 'from_file']"
default-value="search"
name="add-content-dropdown"
color="primary"
@option-click="handleContentOptionClick"
>
<template #search>
<SearchIcon />
<span class="no-wrap"> Add content </span>
</template>
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</DropdownButton>
</span>
</div>
<div>
<div class="table">
<div class="table-row table-head" :class="{ 'show-options': selected.length > 0 }">
<div class="table-cell table-text">
<Checkbox v-model="selectAll" class="select-checkbox" />
</div>
<div v-if="selected.length === 0" class="table-cell table-text name-cell actions-cell">
<Button class="transparent" @click="sortProjects('Name')">
Name
<DropdownIcon v-if="sortColumn === 'Name'" :class="{ down: ascending }" />
</Button>
</div>
<div v-if="selected.length === 0" class="table-cell table-text">
<Button class="transparent" @click="sortProjects('Version')">
Version
<DropdownIcon v-if="sortColumn === 'Version'" :class="{ down: ascending }" />
</Button>
</div>
<div v-if="selected.length === 0" class="table-cell table-text actions-cell">
<Button class="transparent" @click="sortProjects('Enabled')">
Actions
<DropdownIcon v-if="sortColumn === 'Enabled'" :class="{ down: ascending }" />
</Button>
</div>
<div v-else class="options table-cell name-cell">
<Button
class="transparent share"
@click="() => (showingOptions = !showingOptions)"
@mouseover="selectedOption = 'Share'"
>
<MenuIcon :class="{ open: showingOptions }" />
</Button>
<Button
class="transparent share"
@click="shareNames()"
@mouseover="selectedOption = 'Share'"
>
<ShareIcon />
Share
</Button>
<Button
class="transparent trash"
@click="deleteWarning.show()"
@mouseover="selectedOption = 'Delete'"
>
<TrashIcon />
Delete
</Button>
<Button
class="transparent update"
:disabled="offline"
@click="updateAll()"
@mouseover="selectedOption = 'Update'"
>
<UpdatedIcon />
Update
</Button>
<Button
class="transparent"
@click="toggleSelected()"
@mouseover="selectedOption = 'Toggle'"
>
<ToggleIcon />
Toggle
</Button>
</div>
<Button
v-if="isPackLinked"
v-tooltip="'Modpack is up to date'"
:disabled="updatingModpack || !canUpdatePack"
color="secondary"
@click="updateModpack"
>
<UpdatedIcon />
{{ updatingModpack ? 'Updating' : 'Update modpack' }}
</Button>
<Button v-else @click="exportModal.show()">
<PackageIcon />
Export modpack
</Button>
<DropdownButton
v-if="!isPackLinked"
:options="['search', 'from_file']"
default-value="search"
name="add-content-dropdown"
color="primary"
@option-click="handleContentOptionClick"
>
<template #search>
<SearchIcon />
<span class="no-wrap"> Add content </span>
</template>
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</DropdownButton>
</Card>
<Pagination
v-if="projects.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<Card
v-if="projects.length > 0"
class="list-card"
:class="{ static: instance.metadata.linked_data }"
>
<div class="table">
<div class="table-row table-head" :class="{ 'show-options': selected.length > 0 }">
<div v-if="!instance.metadata.linked_data" class="table-cell table-text">
<Checkbox v-model="selectAll" class="select-checkbox" />
</div>
<div v-if="showingOptions && selected.length > 0" class="more-box">
<section v-if="selectedOption === 'Share'" class="options">
<Button class="transparent" @click="shareNames()">
<TextInputIcon />
Share names
</Button>
<Button class="transparent" @click="shareUrls()">
<GlobeIcon />
Share URLs
</Button>
<Button class="transparent" @click="shareFileNames()">
<FileIcon />
Share file names
</Button>
<Button class="transparent" @click="shareMarkdown()">
<CodeIcon />
Share as markdown
</Button>
</section>
<section v-if="selectedOption === 'Delete'" class="options">
<Button class="transparent" @click="deleteWarning.show()">
<TrashIcon />
Delete selected
</Button>
<Button class="transparent" @click="deleteDisabledWarning.show()">
<ToggleIcon />
Delete disabled
</Button>
</section>
<section v-if="selectedOption === 'Update'" class="options">
<Button class="transparent" :disabled="offline" @click="updateAll()">
<UpdatedIcon />
Update all
</Button>
<Button class="transparent" @click="selectUpdatable()">
<CheckIcon />
Select updatable
</Button>
</section>
<section v-if="selectedOption === 'Toggle'" class="options">
<Button class="transparent" @click="enableAll()">
<CheckIcon />
Toggle on
</Button>
<Button class="transparent" @click="disableAll()">
<XIcon />
Toggle off
</Button>
<Button class="transparent" @click="hideShowAll()">
<EyeIcon v-if="hideNonSelected" />
<EyeOffIcon v-else />
{{ hideNonSelected ? 'Show' : 'Hide' }} untoggled
</Button>
</section>
<div v-if="selected.length === 0" class="table-cell table-text name-cell actions-cell">
<Button class="transparent" @click="sortProjects('Name')">
Name
<DropdownIcon v-if="sortColumn === 'Name'" :class="{ down: ascending }" />
</Button>
</div>
<div
v-for="mod in search"
:key="mod.file_name"
class="table-row"
@contextmenu.prevent.stop="(c) => handleRightClick(c, mod)"
>
<div class="table-cell table-text">
<Checkbox
:model-value="selectionMap.get(mod.path)"
class="select-checkbox"
@update:model-value="(newValue) => selectionMap.set(mod.path, newValue)"
/>
</div>
<div class="table-cell table-text name-cell">
<router-link
v-if="mod.slug"
:to="{ path: `/project/${mod.slug}/`, query: { i: props.instance.path } }"
:disabled="offline"
class="mod-content"
>
<Avatar :src="mod.icon" />
<div v-tooltip="`${mod.name} by ${mod.author}`" class="mod-text">
<div class="title">{{ mod.name }}</div>
<span class="no-wrap">by {{ mod.author }}</span>
</div>
</router-link>
<div v-else class="mod-content">
<Avatar :src="mod.icon" />
<span v-tooltip="`${mod.name}`" class="title">{{ mod.name }}</span>
<div v-if="selected.length === 0" class="table-cell table-text version">
<Button class="transparent" @click="sortProjects('Version')">
Version
<DropdownIcon v-if="sortColumn === 'Version'" :class="{ down: ascending }" />
</Button>
</div>
<div v-if="selected.length === 0" class="table-cell table-text actions-cell">
<Button
v-if="!instance.metadata.linked_data"
class="transparent"
@click="sortProjects('Enabled')"
>
Actions
<DropdownIcon v-if="sortColumn === 'Enabled'" :class="{ down: ascending }" />
</Button>
</div>
<div v-else-if="!instance.metadata.linked_data" class="options table-cell name-cell">
<Button
class="transparent share"
@click="() => (showingOptions = !showingOptions)"
@mouseover="selectedOption = 'Share'"
>
<MenuIcon :class="{ open: showingOptions }" />
</Button>
<Button
class="transparent share"
@click="shareNames()"
@mouseover="selectedOption = 'Share'"
>
<ShareIcon />
Share
</Button>
<Button
class="transparent trash"
@click="deleteWarning.show()"
@mouseover="selectedOption = 'Delete'"
>
<TrashIcon />
Delete
</Button>
<Button
class="transparent update"
:disabled="offline"
@click="updateAll()"
@mouseover="selectedOption = 'Update'"
>
<UpdatedIcon />
Update
</Button>
<Button
class="transparent"
@click="toggleSelected()"
@mouseover="selectedOption = 'Toggle'"
>
<ToggleIcon />
Toggle
</Button>
</div>
</div>
<div
v-if="showingOptions && selected.length > 0 && !instance.metadata.linked_data"
class="more-box"
>
<section v-if="selectedOption === 'Share'" class="options">
<Button class="transparent" @click="shareNames()">
<TextInputIcon />
Share names
</Button>
<Button class="transparent" @click="shareUrls()">
<GlobeIcon />
Share URLs
</Button>
<Button class="transparent" @click="shareFileNames()">
<FileIcon />
Share file names
</Button>
<Button class="transparent" @click="shareMarkdown()">
<CodeIcon />
Share as markdown
</Button>
</section>
<section v-if="selectedOption === 'Delete'" class="options">
<Button class="transparent" @click="deleteWarning.show()">
<TrashIcon />
Delete selected
</Button>
<Button class="transparent" @click="deleteDisabledWarning.show()">
<ToggleIcon />
Delete disabled
</Button>
</section>
<section v-if="selectedOption === 'Update'" class="options">
<Button class="transparent" :disabled="offline" @click="updateAll()">
<UpdatedIcon />
Update all
</Button>
<Button class="transparent" @click="selectUpdatable()">
<CheckIcon />
Select updatable
</Button>
</section>
<section v-if="selectedOption === 'Toggle'" class="options">
<Button class="transparent" @click="enableAll()">
<CheckIcon />
Toggle on
</Button>
<Button class="transparent" @click="disableAll()">
<XIcon />
Toggle off
</Button>
<Button class="transparent" @click="hideShowAll()">
<EyeIcon v-if="hideNonSelected" />
<EyeOffIcon v-else />
{{ hideNonSelected ? 'Show' : 'Hide' }} untoggled
</Button>
</section>
</div>
<div
v-for="mod in search.slice((currentPage - 1) * 20, currentPage * 20)"
:key="mod.file_name"
class="table-row"
@contextmenu.prevent.stop="(c) => handleRightClick(c, mod)"
>
<div v-if="!instance.metadata.linked_data" class="table-cell table-text checkbox">
<Checkbox
:model-value="selectionMap.get(mod.path)"
class="select-checkbox"
@update:model-value="(newValue) => selectionMap.set(mod.path, newValue)"
/>
</div>
<div class="table-cell table-text name-cell">
<router-link
v-if="mod.slug"
:to="{ path: `/project/${mod.slug}/`, query: { i: props.instance.path } }"
:disabled="offline"
class="mod-content"
>
<Avatar :src="mod.icon" />
<div v-tooltip="`${mod.name} by ${mod.author}`" class="mod-text">
<div class="title">{{ mod.name }}</div>
<span class="no-wrap">by {{ mod.author }}</span>
</div>
</router-link>
<div v-else class="mod-content">
<Avatar :src="mod.icon" />
<span v-tooltip="`${mod.name}`" class="title">{{ mod.name }}</span>
</div>
<div class="table-cell table-text">
<span v-tooltip="`${mod.version}`">{{ mod.version }}</span>
</div>
<div class="table-cell table-text manage">
<Button v-tooltip="'Remove project'" icon-only @click="removeMod(mod)">
<TrashIcon />
</Button>
<AnimatedLogo
v-if="mod.updating"
class="btn icon-only updating-indicator"
></AnimatedLogo>
<Button
v-else
v-tooltip="'Update project'"
:disabled="!mod.outdated || offline"
icon-only
@click="updateProject(mod)"
>
<UpdatedIcon v-if="mod.outdated" />
<CheckIcon v-else />
</Button>
<input
id="switch-1"
autocomplete="off"
type="checkbox"
class="switch stylized-toggle"
:checked="!mod.disabled"
@change="toggleDisableMod(mod)"
/>
<Button
v-tooltip="`Show ${mod.file_name}`"
icon-only
@click="showProfileInFolder(mod.path)"
>
<FolderOpenIcon />
</Button>
</div>
</div>
<div class="table-cell table-text version">
<span v-tooltip="`${mod.version}`">{{ mod.version }}</span>
</div>
<div class="table-cell table-text manage">
<Button
v-if="!instance.metadata.linked_data"
v-tooltip="'Remove project'"
icon-only
@click="removeMod(mod)"
>
<TrashIcon />
</Button>
<AnimatedLogo
v-if="mod.updating && !instance.metadata.linked_data"
class="btn icon-only updating-indicator"
></AnimatedLogo>
<Button
v-else-if="!instance.metadata.linked_data"
v-tooltip="'Update project'"
:disabled="!mod.outdated || offline"
icon-only
@click="updateProject(mod)"
>
<UpdatedIcon v-if="mod.outdated" />
<CheckIcon v-else />
</Button>
<input
v-if="!instance.metadata.linked_data"
id="switch-1"
autocomplete="off"
type="checkbox"
class="switch stylized-toggle"
:checked="!mod.disabled"
@change="toggleDisableMod(mod)"
/>
<Button
v-tooltip="`Show ${mod.file_name}`"
icon-only
@click="showProfileInFolder(mod.path)"
>
<FolderOpenIcon />
</Button>
</div>
</div>
</div>
@@ -309,6 +347,7 @@
share-title="Sharing modpack content"
share-text="Check out the projects I'm using in my modpack!"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
</template>
<script setup>
import {
@@ -320,7 +359,6 @@ import {
SearchIcon,
UpdatedIcon,
AnimatedLogo,
Chips,
FolderOpenIcon,
Checkbox,
formatProjectType,
@@ -335,6 +373,8 @@ import {
EyeOffIcon,
ShareModal,
CodeIcon,
Pagination,
DropdownSelect,
} from 'omorphia'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -344,6 +384,7 @@ import {
remove_project,
toggle_disable_project,
update_all,
update_managed_modrinth,
update_project,
} from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
@@ -352,7 +393,8 @@ import { open } from '@tauri-apps/api/dialog'
import { listen } from '@tauri-apps/api/event'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import { showProfileInFolder } from '@/helpers/utils.js'
import { MenuIcon, ToggleIcon, TextInputIcon, AddProjectImage } from '@/assets/icons'
import { MenuIcon, ToggleIcon, TextInputIcon, AddProjectImage, PackageIcon } from '@/assets/icons'
import ExportModal from '@/components/ui/ExportModal.vue'
const router = useRouter()
@@ -380,7 +422,15 @@ const props = defineProps({
const projects = ref([])
const selectionMap = ref(new Map())
const showingOptions = ref(false)
const isPackLinked = computed(() => {
return props.instance.metadata.linked_data
})
const canUpdatePack = computed(() => {
return props.instance.metadata.linked_data.version_id !== props.instance.modrinth_update_version
})
const exportModal = ref(null)
console.log(props.instance)
const initProjects = (initInstance) => {
projects.value = []
if (!initInstance || !initInstance.projects) return
@@ -467,6 +517,7 @@ const selectedOption = ref('Share')
const shareModal = ref(null)
const ascending = ref(true)
const sortColumn = ref('Name')
const currentPage = ref(1)
const selected = computed(() =>
Array.from(selectionMap.value)
@@ -776,6 +827,13 @@ const handleContentOptionClick = async (args) => {
}
}
const updatingModpack = ref(false)
const updateModpack = async () => {
updatingModpack.value = true
await update_managed_modrinth(props.instance.path).catch(handleError)
updatingModpack.value = false
}
watch(selectAll, () => {
for (const [key, value] of Array.from(selectionMap.value)) {
if (value !== selectAll.value) {
@@ -791,6 +849,11 @@ const unlisten = await listen('tauri://file-drop', async (event) => {
}
initProjects(await get(props.instance.path).catch(handleError))
})
const switchPage = (page) => {
currentPage.value = page
}
onUnmounted(() => {
unlisten()
})
@@ -827,6 +890,26 @@ onUnmounted(() => {
}
}
.static {
.table-row {
grid-template-areas: 'manage name version';
grid-template-columns: 4.25rem 1fr 1fr;
}
.name-cell {
grid-area: name;
}
.version {
grid-area: version;
}
.manage {
justify-content: center;
grid-area: manage;
}
}
.table-cell {
align-items: center;
}
@@ -841,10 +924,56 @@ onUnmounted(() => {
.mod-card {
display: flex;
flex-direction: column;
flex-direction: row;
flex-wrap: wrap;
gap: var(--gap-sm);
justify-content: center;
overflow: hidden;
justify-content: flex-start;
margin-bottom: 0.5rem;
white-space: nowrap;
align-items: center;
:deep(.dropdown-row) {
.btn {
height: 2.5rem !important;
}
}
.btn {
height: 2.5rem;
}
.dropdown-input {
flex-grow: 1;
.iconified-input {
width: 100%;
input {
flex-basis: unset;
}
}
:deep(.animated-dropdown) {
.render-down {
border-radius: var(--radius-md) 0 0 var(--radius-md) !important;
}
.options-wrapper {
margin-top: 0.25rem;
width: unset;
border-radius: var(--radius-md);
}
.options {
border-radius: var(--radius-md);
border: 1px solid var(--color);
}
}
}
}
.list-card {
margin-top: 0.5rem;
}
.text-combo {
@@ -1016,4 +1145,10 @@ onUnmounted(() => {
margin: 0;
}
}
.dropdown-input {
.selected {
height: 2.5rem;
}
}
</style>

View File

@@ -21,7 +21,12 @@
<div class="input-row">
<p class="input-label">Game Version</p>
<div class="versions">
<DropdownSelect v-model="gameVersion" :options="selectableGameVersions" render-up />
<DropdownSelect
v-model="gameVersion"
:options="selectableGameVersions"
name="Game Version Dropdown"
render-up
/>
<Checkbox v-model="showSnapshots" class="filter-checkbox" label="Include snapshots" />
</div>
</div>
@@ -31,6 +36,7 @@
:model-value="selectableLoaderVersions[loaderVersionIndex]"
:options="selectableLoaderVersions"
:display-name="(option) => option?.id"
name="Version selector"
render-up
@change="(value) => (loaderVersionIndex = value.index)"
/>
@@ -85,7 +91,14 @@
<label for="project-name">
<span class="label__title">Name</span>
</label>
<input id="profile-name" v-model="title" autocomplete="off" maxlength="80" type="text" />
<input
id="profile-name"
v-model="title"
autocomplete="off"
maxlength="80"
type="text"
:disabled="instance.metadata.linked_data"
/>
<div class="adjacent-input">
<label for="edit-versions">
@@ -197,7 +210,9 @@
<div class="adjacent-input">
<label for="fullscreen">
<span class="label__title">Fullscreen</span>
<span class="label__description"> Make the game start in full screen when launched. </span>
<span class="label__description">
Make the game start in full screen when launched (using options.txt).
</span>
</label>
<Checkbox id="fullscreen" v-model="fullscreenSetting" :disabled="!overrideWindowSettings" />
</div>
@@ -289,6 +304,17 @@
<span class="label__title size-card-header">Instance management</span>
</h3>
</div>
<div v-if="instance.metadata.linked_data" class="adjacent-input">
<label for="repair-profile">
<span class="label__title">Unpair instance</span>
<span class="label__description">
Removes the link to an external modpack on the instance. This allows you to edit modpacks
you download through the browse page but you will not be able to update the instance from
a new version of a modpack if you do this.
</span>
</label>
<Button id="repair-profile" @click="unpairProfile"> <XIcon /> Unpair </Button>
</div>
<div class="adjacent-input">
<label for="repair-profile">
<span class="label__title">Repair instance</span>
@@ -297,14 +323,14 @@
launching due to launcher-related errors.
</span>
</label>
<button
<Button
id="repair-profile"
class="btn btn-highlight"
color="highlight"
:disabled="repairing || offline"
@click="repairProfile"
>
<HammerIcon /> Repair
</button>
</Button>
</div>
<div v-if="props.instance.modrinth_update_version" class="adjacent-input">
<label for="repair-profile">
@@ -314,16 +340,15 @@
launching due to your instance diverging from the Modrinth modpack.
</span>
</label>
<button
<Button
id="repair-profile"
class="btn btn-highlight"
color="highlight"
:disabled="repairing || offline"
@click="repairModpack"
>
<DownloadIcon /> Reinstall
</button>
</Button>
</div>
<div class="adjacent-input">
<label for="delete-profile">
<span class="label__title">Delete instance</span>
@@ -332,14 +357,14 @@
no way to recover it.
</span>
</label>
<button
<Button
id="delete-profile"
class="btn btn-danger"
color="danger"
:disabled="removing"
@click="$refs.modal_confirm.show()"
>
<TrashIcon /> Delete
</button>
</Button>
</div>
</Card>
</template>
@@ -361,6 +386,7 @@ import {
HammerIcon,
DownloadIcon,
ModalConfirm,
Button,
} from 'omorphia'
import { Multiselect } from 'vue-multiselect'
import { useRouter } from 'vue-router'
@@ -406,11 +432,9 @@ const groups = ref(props.instance.metadata.groups)
const instancesList = Object.values(await list(true))
const availableGroups = ref([
...new Set(
instancesList.reduce((acc, obj) => {
return acc.concat(obj.metadata.groups)
}, [])
),
...instancesList.reduce((acc, obj) => {
return acc.concat(obj.metadata.groups)
}, []),
])
async function resetIcon() {
@@ -465,6 +489,8 @@ const hooks = ref(props.instance.hooks ?? globalSettings.hooks)
const fullscreenSetting = ref(!!props.instance.fullscreen)
const unlinkModpack = ref(false)
watch(
[
title,
@@ -483,11 +509,12 @@ watch(
fullscreenSetting,
overrideHooks,
hooks,
unlinkModpack,
],
async () => {
const editProfile = {
metadata: {
name: title.value.trim().substring(0, 16) ?? 'Instance',
name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
loader_version: props.instance.metadata.loader_version,
linked_data: props.instance.metadata.linked_data,
@@ -537,6 +564,10 @@ watch(
editProfile.hooks = hooks.value
}
if (unlinkModpack.value) {
editProfile.metadata.linked_data = null
}
await edit(props.instance.path, editProfile)
},
{ deep: true }
@@ -544,6 +575,10 @@ watch(
const repairing = ref(false)
async function unpairProfile() {
unlinkModpack.value = true
}
async function repairProfile() {
repairing.value = true
await install(props.instance.path).catch(handleError)

View File

@@ -50,6 +50,7 @@
</Button>
<a
class="open btn icon-only"
target="_blank"
:href="
expandedGalleryItem.url
? expandedGalleryItem.url

View File

@@ -37,6 +37,7 @@
(cat) => data.categories.includes(cat.name) && cat.project_type === 'mod'
)
"
type="ignored"
>
<EnvironmentIndicator
:client-side="data.client_side"
@@ -167,7 +168,7 @@
</Card>
</div>
<div v-if="data" class="content-container">
<Promotion />
<Promotion query-param="?r=launcher" />
<Card class="tabs">
<NavRow
v-if="data.gallery.length > 0"
@@ -205,6 +206,7 @@
:versions="versions"
:members="members"
:dependencies="dependencies"
:instance="instance"
:install="install"
:installed="installed"
:installing="installing"

View File

@@ -83,12 +83,7 @@
<Card v-if="displayDependencies.length > 0">
<h2>Dependencies</h2>
<div v-for="dependency in displayDependencies" :key="dependency.title">
<router-link
v-if="dependency.link"
class="btn dependency"
:to="dependency.link"
@click="testTest"
>
<router-link v-if="dependency.link" class="btn dependency" :to="dependency.link">
<Avatar size="sm" :src="dependency.icon" />
<div>
<span class="title"> {{ dependency.title }} </span> <br />

View File

@@ -167,8 +167,8 @@ import { computed, ref, watch } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js'
const filterVersions = ref([])
const filterLoader = ref([])
const filterGameVersions = ref([])
const filterLoader = ref(props.instance ? [props.instance?.metadata?.loader] : [])
const filterGameVersions = ref(props.instance ? [props.instance?.metadata?.game_version] : [])
const currentPage = ref(1)

View File

@@ -15,17 +15,18 @@ use tokio::time::{sleep, Duration};
// 3) call the authenticate_await_complete_flow() function to get the credentials (like you would in the frontend)
pub async fn authenticate_run() -> theseus::Result<Credentials> {
println!("A browser window will now open, follow the login flow there.");
let url = auth::authenticate_begin_flow().await?;
let login = auth::authenticate_begin_flow().await?;
println!("URL {}", url.as_str());
webbrowser::open(url.as_str())
.map_err(|e| IOError::with_path(e, url.as_str()))?;
println!("URL {}", login.verification_uri.as_str());
println!("Code {}", login.user_code.as_str());
webbrowser::open(login.verification_uri.as_str())
.map_err(|e| IOError::with_path(e, login.verification_uri.as_str()))?;
let credentials = auth::authenticate_await_complete_flow().await?;
State::sync().await?;
println!("Logged in user {}.", credentials.0.username);
Ok(credentials.0)
println!("Logged in user {}.", credentials.username);
Ok(credentials)
}
#[tokio::main]
@@ -85,6 +86,7 @@ async fn main() -> theseus::Result<()> {
None,
None,
None,
None,
)
.await?;