Launcher Auth (#450)

* Launcher Auth

* Finish auth

* final fixes
This commit is contained in:
Geometrically 2023-08-04 23:38:34 -07:00 committed by GitHub
parent a35dd67b77
commit 47e28d24c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1200 additions and 477 deletions

6
Cargo.lock generated
View File

@ -4609,7 +4609,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus" name = "theseus"
version = "0.3.1" version = "0.4.0"
dependencies = [ dependencies = [
"async-recursion", "async-recursion",
"async-tungstenite", "async-tungstenite",
@ -4654,7 +4654,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_cli" name = "theseus_cli"
version = "0.3.1" version = "0.4.0"
dependencies = [ dependencies = [
"argh", "argh",
"color-eyre", "color-eyre",
@ -4681,7 +4681,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_gui" name = "theseus_gui"
version = "0.3.1" version = "0.4.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"cocoa", "cocoa",

View File

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

View File

@ -20,7 +20,8 @@ pub async fn authenticate_begin_flow() -> crate::Result<url::Url> {
/// This completes the authentication flow quasi-synchronously, returning the credentials /// This completes the authentication flow quasi-synchronously, returning the credentials
/// This can be used in conjunction with 'authenticate_begin_flow' /// This can be used in conjunction with 'authenticate_begin_flow'
/// to call authenticate and call the flow from the frontend. /// to call authenticate and call the flow from the frontend.
pub async fn authenticate_await_complete_flow() -> crate::Result<Credentials> { pub async fn authenticate_await_complete_flow(
) -> crate::Result<(Credentials, Option<String>)> {
let credentials = AuthTask::await_auth_completion().await?; let credentials = AuthTask::await_auth_completion().await?;
Ok(credentials) Ok(credentials)
} }
@ -38,7 +39,7 @@ pub async fn cancel_flow() -> crate::Result<()> {
#[theseus_macros::debug_pin] #[theseus_macros::debug_pin]
pub async fn authenticate( pub async fn authenticate(
browser_url: oneshot::Sender<url::Url>, browser_url: oneshot::Sender<url::Url>,
) -> crate::Result<Credentials> { ) -> crate::Result<(Credentials, Option<String>)> {
let mut flow = inner::HydraAuthFlow::new().await?; let mut flow = inner::HydraAuthFlow::new().await?;
let state = State::get().await?; let state = State::get().await?;
@ -52,12 +53,12 @@ pub async fn authenticate(
let credentials = flow.extract_credentials(&state.fetch_semaphore).await?; let credentials = flow.extract_credentials(&state.fetch_semaphore).await?;
{ {
let mut users = state.users.write().await; let mut users = state.users.write().await;
users.insert(&credentials).await?; users.insert(&credentials.0).await?;
} }
if state.settings.read().await.default_user.is_none() { if state.settings.read().await.default_user.is_none() {
let mut settings = state.settings.write().await; let mut settings = state.settings.write().await;
settings.default_user = Some(credentials.id); settings.default_user = Some(credentials.0.id);
} }
Ok(credentials) Ok(credentials)
@ -79,8 +80,17 @@ pub async fn refresh(user: uuid::Uuid) -> crate::Result<Credentials> {
})?; })?;
let fetch_semaphore = &state.fetch_semaphore; let fetch_semaphore = &state.fetch_semaphore;
if Utc::now() > credentials.expires { if Utc::now() > credentials.expires
inner::refresh_credentials(&mut credentials, fetch_semaphore).await?; && inner::refresh_credentials(&mut credentials, fetch_semaphore)
.await
.is_err()
{
users.remove(credentials.id).await?;
return Err(crate::ErrorKind::OtherError(
"Please re-authenticate with your Minecraft account!".to_string(),
)
.as_error());
} }
users.insert(&credentials).await?; users.insert(&credentials).await?;

View File

@ -4,6 +4,7 @@ use serde::Deserialize;
use std::path::PathBuf; use std::path::PathBuf;
use crate::event::emit::{emit_loading, init_loading}; use crate::event::emit::{emit_loading, init_loading};
use crate::state::CredentialsStore;
use crate::util::fetch::{fetch_advanced, fetch_json}; use crate::util::fetch::{fetch_advanced, fetch_json};
use crate::util::io; use crate::util::io;
use crate::util::jre::extract_java_majorminor_version; use crate::util::jre::extract_java_majorminor_version;
@ -97,6 +98,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
None, None,
None, None,
&state.fetch_semaphore, &state.fetch_semaphore,
&CredentialsStore(None),
).await?; ).await?;
emit_loading(&loading_bar, 10.0, Some("Downloading java version")).await?; emit_loading(&loading_bar, 10.0, Some("Downloading java version")).await?;
@ -109,6 +111,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
None, None,
Some((&loading_bar, 80.0)), Some((&loading_bar, 80.0)),
&state.fetch_semaphore, &state.fetch_semaphore,
&CredentialsStore(None),
) )
.await?; .await?;

View File

@ -4,6 +4,7 @@ pub mod handler;
pub mod jre; pub mod jre;
pub mod logs; pub mod logs;
pub mod metadata; pub mod metadata;
pub mod mr_auth;
pub mod pack; pub mod pack;
pub mod process; pub mod process;
pub mod profile; pub mod profile;
@ -14,9 +15,9 @@ pub mod tags;
pub mod data { pub mod data {
pub use crate::state::{ pub use crate::state::{
DirectoryInfo, Hooks, JavaSettings, LinkedData, MemorySettings, DirectoryInfo, Hooks, JavaSettings, LinkedData, MemorySettings,
ModLoader, ModrinthProject, ModrinthTeamMember, ModrinthUser, ModLoader, ModrinthCredentials, ModrinthCredentialsResult,
ModrinthVersion, ProfileMetadata, ProjectMetadata, Settings, Theme, ModrinthProject, ModrinthTeamMember, ModrinthUser, ModrinthVersion,
WindowSize, ProfileMetadata, ProjectMetadata, Settings, Theme, WindowSize,
}; };
} }

157
theseus/src/api/mr_auth.rs Normal file
View File

@ -0,0 +1,157 @@
use crate::state::{
ModrinthAuthFlow, ModrinthCredentials, ModrinthCredentialsResult,
};
use crate::ErrorKind;
#[tracing::instrument]
pub async fn authenticate_begin_flow(provider: &str) -> crate::Result<String> {
let state = crate::State::get().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)
}
#[tracing::instrument]
pub async fn authenticate_await_complete_flow(
) -> crate::Result<ModrinthCredentialsResult> {
let state = crate::State::get().await?;
let mut write = state.modrinth_auth_flow.write().await;
if let Some(ref mut flow) = *write {
let creds = flow.extract_credentials(&state.fetch_semaphore).await?;
if let ModrinthCredentialsResult::Credentials(creds) = &creds {
let mut write = state.credentials.write().await;
write.login(creds.clone()).await?;
}
Ok(creds)
} else {
Err(ErrorKind::OtherError(
"No active Modrinth authenication flow!".to_string(),
)
.into())
}
}
#[tracing::instrument]
pub async fn cancel_flow() -> crate::Result<()> {
let state = crate::State::get().await?;
let mut write = state.modrinth_auth_flow.write().await;
if let Some(ref mut flow) = *write {
flow.close().await?;
}
*write = None;
Ok(())
}
pub async fn login_password(
username: &str,
password: &str,
challenge: &str,
) -> crate::Result<ModrinthCredentialsResult> {
let state = crate::State::get().await?;
let creds = crate::state::login_password(
username,
password,
challenge,
&state.fetch_semaphore,
)
.await?;
if let ModrinthCredentialsResult::Credentials(creds) = &creds {
let mut write = state.credentials.write().await;
write.login(creds.clone()).await?;
}
Ok(creds)
}
#[tracing::instrument]
pub async fn login_2fa(
code: &str,
flow: &str,
) -> crate::Result<ModrinthCredentials> {
let state = crate::State::get().await?;
let creds =
crate::state::login_2fa(code, flow, &state.fetch_semaphore).await?;
let mut write = state.credentials.write().await;
write.login(creds.clone()).await?;
Ok(creds)
}
#[tracing::instrument]
pub async fn login_minecraft(
flow: &str,
) -> crate::Result<ModrinthCredentialsResult> {
let state = crate::State::get().await?;
let creds =
crate::state::login_minecraft(flow, &state.fetch_semaphore).await?;
if let ModrinthCredentialsResult::Credentials(creds) = &creds {
let mut write = state.credentials.write().await;
write.login(creds.clone()).await?;
}
Ok(creds)
}
#[tracing::instrument]
pub async fn create_account(
username: &str,
email: &str,
password: &str,
challenge: &str,
sign_up_newsletter: bool,
) -> crate::Result<ModrinthCredentials> {
let state = crate::State::get().await?;
let creds = crate::state::create_account(
username,
email,
password,
challenge,
sign_up_newsletter,
&state.fetch_semaphore,
)
.await?;
let mut write = state.credentials.write().await;
write.login(creds.clone()).await?;
Ok(creds)
}
#[tracing::instrument]
pub async fn refresh() -> crate::Result<()> {
let state = crate::State::get().await?;
let mut write = state.credentials.write().await;
crate::state::refresh_credentials(&mut write, &state.fetch_semaphore)
.await?;
Ok(())
}
#[tracing::instrument]
pub async fn logout() -> crate::Result<()> {
let state = crate::State::get().await?;
let mut write = state.credentials.write().await;
write.logout().await?;
Ok(())
}
#[tracing::instrument]
pub async fn get_credentials() -> crate::Result<Option<ModrinthCredentials>> {
let state = crate::State::get().await?;
let read = state.credentials.read().await;
Ok(read.0.clone())
}

View File

@ -2,6 +2,7 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::state::CredentialsStore;
use crate::{ use crate::{
prelude::{ModLoader, ProfilePathId}, prelude::{ModLoader, ProfilePathId},
state::ProfileInstallStage, state::ProfileInstallStage,
@ -90,8 +91,13 @@ pub async fn import_curseforge(
thumbnail_url: Some(thumbnail_url), thumbnail_url: Some(thumbnail_url),
}) = minecraft_instance.installed_modpack.clone() }) = minecraft_instance.installed_modpack.clone()
{ {
let icon_bytes = let icon_bytes = fetch(
fetch(&thumbnail_url, None, &state.fetch_semaphore).await?; &thumbnail_url,
None,
&state.fetch_semaphore,
&CredentialsStore(None),
)
.await?;
let filename = thumbnail_url.rsplit('/').last(); let filename = thumbnail_url.rsplit('/').last();
if let Some(filename) = filename { if let Some(filename) = filename {
icon = Some( icon = Some(

View File

@ -192,12 +192,14 @@ pub async fn generate_pack_from_version_id(
.await?; .await?;
emit_loading(&loading_bar, 0.0, Some("Fetching version")).await?; emit_loading(&loading_bar, 0.0, Some("Fetching version")).await?;
let creds = state.credentials.read().await;
let version: ModrinthVersion = fetch_json( let version: ModrinthVersion = fetch_json(
Method::GET, Method::GET,
&format!("{}version/{}", MODRINTH_API_URL, version_id), &format!("{}version/{}", MODRINTH_API_URL, version_id),
None, None,
None, None,
&state.fetch_semaphore, &state.fetch_semaphore,
&creds,
) )
.await?; .await?;
emit_loading(&loading_bar, 10.0, None).await?; emit_loading(&loading_bar, 10.0, None).await?;
@ -225,6 +227,7 @@ pub async fn generate_pack_from_version_id(
None, None,
Some((&loading_bar, 70.0)), Some((&loading_bar, 70.0)),
&state.fetch_semaphore, &state.fetch_semaphore,
&creds,
) )
.await?; .await?;
emit_loading(&loading_bar, 0.0, Some("Fetching project metadata")).await?; emit_loading(&loading_bar, 0.0, Some("Fetching project metadata")).await?;
@ -235,13 +238,16 @@ pub async fn generate_pack_from_version_id(
None, None,
None, None,
&state.fetch_semaphore, &state.fetch_semaphore,
&creds,
) )
.await?; .await?;
emit_loading(&loading_bar, 10.0, Some("Retrieving icon")).await?; emit_loading(&loading_bar, 10.0, Some("Retrieving icon")).await?;
let icon = if let Some(icon_url) = project.icon_url { let icon = if let Some(icon_url) = project.icon_url {
let state = State::get().await?; let state = State::get().await?;
let icon_bytes = fetch(&icon_url, None, &state.fetch_semaphore).await?; let icon_bytes =
fetch(&icon_url, None, &state.fetch_semaphore, &creds).await?;
drop(creds);
let filename = icon_url.rsplit('/').next(); let filename = icon_url.rsplit('/').next();

View File

@ -168,6 +168,7 @@ pub async fn install_zipped_mrpack_files(
} }
} }
let creds = state.credentials.read().await;
let file = fetch_mirrors( let file = fetch_mirrors(
&project &project
.downloads .downloads
@ -176,8 +177,10 @@ pub async fn install_zipped_mrpack_files(
.collect::<Vec<&str>>(), .collect::<Vec<&str>>(),
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x), project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
&state.fetch_semaphore, &state.fetch_semaphore,
&creds,
) )
.await?; .await?;
drop(creds);
let path = let path =
std::path::Path::new(&project.path).components().next(); std::path::Path::new(&project.path).components().next();

View File

@ -1,4 +1,6 @@
//! Authentication flow based on Hydra //! 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 crate::util::fetch::{fetch_advanced, fetch_json, FetchSemaphore};
use async_tungstenite as ws; use async_tungstenite as ws;
use chrono::{prelude::*, Duration}; use chrono::{prelude::*, Duration};
@ -10,7 +12,7 @@ use url::Url;
lazy_static! { lazy_static! {
static ref HYDRA_URL: Url = static ref HYDRA_URL: Url =
Url::parse("https://staging-api.modrinth.com/v2/auth/minecraft/") Url::parse(&format!("{MODRINTH_API_URL}auth/minecraft/"))
.expect("Hydra URL parse failed"); .expect("Hydra URL parse failed");
} }
@ -40,7 +42,7 @@ struct TokenJSON {
token: String, token: String,
refresh_token: String, refresh_token: String,
expires_after: u32, expires_after: u32,
flow: String, flow: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -68,7 +70,7 @@ pub struct HydraAuthFlow<S: AsyncRead + AsyncWrite + Unpin> {
impl HydraAuthFlow<ws::tokio::ConnectStream> { impl HydraAuthFlow<ws::tokio::ConnectStream> {
pub async fn new() -> crate::Result<Self> { pub async fn new() -> crate::Result<Self> {
let (socket, _) = ws::tokio::connect_async( let (socket, _) = ws::tokio::connect_async(
"wss://staging-api.modrinth.com/v2/auth/minecraft/ws", "wss://api.modrinth.com/v2/auth/minecraft/ws",
) )
.await?; .await?;
Ok(Self { socket }) Ok(Self { socket })
@ -96,7 +98,7 @@ impl HydraAuthFlow<ws::tokio::ConnectStream> {
pub async fn extract_credentials( pub async fn extract_credentials(
&mut self, &mut self,
semaphore: &FetchSemaphore, semaphore: &FetchSemaphore,
) -> crate::Result<Credentials> { ) -> crate::Result<(Credentials, Option<String>)> {
// Minecraft bearer token // Minecraft bearer token
let token_resp = self let token_resp = self
.socket .socket
@ -117,14 +119,17 @@ impl HydraAuthFlow<ws::tokio::ConnectStream> {
let info = fetch_info(&token.token, semaphore).await?; let info = fetch_info(&token.token, semaphore).await?;
// Return structure from response // Return structure from response
Ok(Credentials { Ok((
username: info.name, Credentials {
id: info.id, username: info.name,
refresh_token: token.refresh_token, id: info.id,
access_token: token.token, refresh_token: token.refresh_token,
expires, access_token: token.token,
_ctor_scope: std::marker::PhantomData, expires,
}) _ctor_scope: std::marker::PhantomData,
},
token.flow,
))
} }
} }
@ -134,10 +139,11 @@ pub async fn refresh_credentials(
) -> crate::Result<()> { ) -> crate::Result<()> {
let resp = fetch_json::<TokenJSON>( let resp = fetch_json::<TokenJSON>(
Method::POST, Method::POST,
"https://staging-api.modrinth.com/v2/auth/minecraft/refresh", &format!("{MODRINTH_API_URL}auth/minecraft/refresh"),
None, None,
Some(serde_json::json!({ "refresh_token": credentials.refresh_token })), Some(serde_json::json!({ "refresh_token": credentials.refresh_token })),
semaphore, semaphore,
&CredentialsStore(None),
) )
.await?; .await?;
@ -162,6 +168,7 @@ async fn fetch_info(
Some(("Authorization", &format!("Bearer {token}"))), Some(("Authorization", &format!("Bearer {token}"))),
None, None,
semaphore, semaphore,
&CredentialsStore(None),
) )
.await?; .await?;
let value = serde_json::from_slice(&result)?; let value = serde_json::from_slice(&result)?;

View File

@ -1,5 +1,6 @@
//! Downloader for Minecraft data //! Downloader for Minecraft data
use crate::state::CredentialsStore;
use crate::{ use crate::{
event::{ event::{
emit::{emit_loading, loading_try_for_each_concurrent}, emit::{emit_loading, loading_try_for_each_concurrent},
@ -127,6 +128,7 @@ pub async fn download_client(
&client_download.url, &client_download.url,
Some(&client_download.sha1), Some(&client_download.sha1),
&st.fetch_semaphore, &st.fetch_semaphore,
&CredentialsStore(None),
) )
.await?; .await?;
write(&path, &bytes, &st.io_semaphore).await?; write(&path, &bytes, &st.io_semaphore).await?;
@ -206,7 +208,7 @@ pub async fn download_assets(
async { async {
if !resource_path.exists() { if !resource_path.exists() {
let resource = fetch_cell let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore)) .get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore, &CredentialsStore(None)))
.await?; .await?;
write(&resource_path, resource, &st.io_semaphore).await?; write(&resource_path, resource, &st.io_semaphore).await?;
tracing::trace!("Fetched asset with hash {hash}"); tracing::trace!("Fetched asset with hash {hash}");
@ -216,7 +218,7 @@ pub async fn download_assets(
async { async {
if with_legacy { if with_legacy {
let resource = fetch_cell let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore)) .get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore, &CredentialsStore(None)))
.await?; .await?;
let resource_path = st.directories.legacy_assets_dir().await.join( let resource_path = st.directories.legacy_assets_dir().await.join(
name.replace('/', &String::from(std::path::MAIN_SEPARATOR)) name.replace('/', &String::from(std::path::MAIN_SEPARATOR))
@ -273,7 +275,7 @@ pub async fn download_libraries(
artifact: Some(ref artifact), artifact: Some(ref artifact),
.. ..
}) => { }) => {
let bytes = fetch(&artifact.url, Some(&artifact.sha1), &st.fetch_semaphore) let bytes = fetch(&artifact.url, Some(&artifact.sha1), &st.fetch_semaphore, &CredentialsStore(None))
.await?; .await?;
write(&path, &bytes, &st.io_semaphore).await?; write(&path, &bytes, &st.io_semaphore).await?;
tracing::trace!("Fetched library {} to path {:?}", &library.name, &path); tracing::trace!("Fetched library {} to path {:?}", &library.name, &path);
@ -288,7 +290,7 @@ pub async fn download_libraries(
&artifact_path &artifact_path
].concat(); ].concat();
let bytes = fetch(&url, None, &st.fetch_semaphore).await?; let bytes = fetch(&url, None, &st.fetch_semaphore, &CredentialsStore(None)).await?;
write(&path, &bytes, &st.io_semaphore).await?; write(&path, &bytes, &st.io_semaphore).await?;
tracing::trace!("Fetched library {} to path {:?}", &library.name, &path); tracing::trace!("Fetched library {} to path {:?}", &library.name, &path);
Ok::<_, crate::Error>(()) Ok::<_, crate::Error>(())
@ -314,7 +316,7 @@ pub async fn download_libraries(
); );
if let Some(native) = classifiers.get(&parsed_key) { if let Some(native) = classifiers.get(&parsed_key) {
let data = fetch(&native.url, Some(&native.sha1), &st.fetch_semaphore).await?; let data = fetch(&native.url, Some(&native.sha1), &st.fetch_semaphore, &CredentialsStore(None)).await?;
let reader = std::io::Cursor::new(&data); let reader = std::io::Cursor::new(&data);
if let Ok(mut archive) = zip::ZipArchive::new(reader) { if let Ok(mut archive) = zip::ZipArchive::new(reader) {
match archive.extract(st.directories.version_natives_dir(version).await) { match archive.extract(st.directories.version_natives_dir(version).await) {

View File

@ -6,7 +6,10 @@ use tokio::task::JoinHandle;
// A wrapper over the authentication task that allows it to be called from the frontend // A wrapper over the authentication task that allows it to be called from the frontend
// without caching the task handle in the frontend // without caching the task handle in the frontend
pub struct AuthTask(Option<JoinHandle<crate::Result<Credentials>>>); pub struct AuthTask(
#[allow(clippy::type_complexity)]
Option<JoinHandle<crate::Result<(Credentials, Option<String>)>>>,
);
impl AuthTask { impl AuthTask {
pub fn new() -> AuthTask { pub fn new() -> AuthTask {
@ -37,7 +40,8 @@ impl AuthTask {
Ok(url) Ok(url)
} }
pub async fn await_auth_completion() -> crate::Result<Credentials> { pub async fn await_auth_completion(
) -> crate::Result<(Credentials, Option<String>)> {
// Gets the task handle from the state, replacing with None // Gets the task handle from the state, replacing with None
let task = { let task = {
let state = crate::State::get().await?; let state = crate::State::get().await?;

View File

@ -52,6 +52,9 @@ pub use self::safe_processes::*;
mod discord; mod discord;
pub use self::discord::*; pub use self::discord::*;
mod mr_auth;
pub use self::mr_auth::*;
// Global state // Global state
// RwLock on state only has concurrent reads, except for config dir change which takes control of the State // RwLock on state only has concurrent reads, except for config dir change which takes control of the State
static LAUNCHER_STATE: OnceCell<RwLock<State>> = OnceCell::const_new(); static LAUNCHER_STATE: OnceCell<RwLock<State>> = OnceCell::const_new();
@ -77,16 +80,20 @@ pub struct State {
pub settings: RwLock<Settings>, pub settings: RwLock<Settings>,
/// Reference to minecraft process children /// Reference to minecraft process children
pub children: RwLock<Children>, pub children: RwLock<Children>,
/// Authentication flow
pub auth_flow: RwLock<AuthTask>,
/// Launcher profile metadata /// Launcher profile metadata
pub(crate) profiles: RwLock<Profiles>, pub(crate) profiles: RwLock<Profiles>,
/// Launcher user account info
pub(crate) users: RwLock<Users>,
/// Launcher tags /// Launcher tags
pub(crate) tags: RwLock<Tags>, pub(crate) tags: RwLock<Tags>,
/// Launcher processes that should be safely exited on shutdown /// Launcher processes that should be safely exited on shutdown
pub(crate) safety_processes: RwLock<SafeProcesses>, pub(crate) safety_processes: RwLock<SafeProcesses>,
/// Launcher user account info
pub(crate) users: RwLock<Users>,
/// Authentication flow
pub auth_flow: RwLock<AuthTask>,
/// Modrinth Credentials Store
pub credentials: RwLock<CredentialsStore>,
/// Modrinth auth flow
pub modrinth_auth_flow: RwLock<Option<ModrinthAuthFlow>>,
/// Discord RPC /// Discord RPC
pub discord_rpc: DiscordGuard, pub discord_rpc: DiscordGuard,
@ -159,15 +166,18 @@ impl State {
!is_offline, !is_offline,
&io_semaphore, &io_semaphore,
&fetch_semaphore, &fetch_semaphore,
&CredentialsStore(None),
); );
let users_fut = Users::init(&directories, &io_semaphore); let users_fut = Users::init(&directories, &io_semaphore);
let creds_fut = CredentialsStore::init(&directories, &io_semaphore);
// Launcher data // Launcher data
let (metadata, profiles, tags, users) = loading_join! { let (metadata, profiles, tags, users, creds) = loading_join! {
Some(&loading_bar), 70.0, Some("Loading metadata"); Some(&loading_bar), 70.0, Some("Loading metadata");
metadata_fut, metadata_fut,
profiles_fut, profiles_fut,
tags_fut, tags_fut,
users_fut, users_fut,
creds_fut,
}?; }?;
let children = Children::new(); let children = Children::new();
@ -198,10 +208,12 @@ impl State {
users: RwLock::new(users), users: RwLock::new(users),
children: RwLock::new(children), children: RwLock::new(children),
auth_flow: RwLock::new(auth_flow), auth_flow: RwLock::new(auth_flow),
credentials: RwLock::new(creds),
tags: RwLock::new(tags), tags: RwLock::new(tags),
discord_rpc, discord_rpc,
safety_processes: RwLock::new(safety_processes), safety_processes: RwLock::new(safety_processes),
file_watcher: RwLock::new(file_watcher), file_watcher: RwLock::new(file_watcher),
modrinth_auth_flow: RwLock::new(None),
})) }))
} }
@ -222,6 +234,11 @@ impl State {
/// Updates state with data from the web, if we are online /// Updates state with data from the web, if we are online
pub fn update() { pub fn update() {
tokio::task::spawn(Metadata::update());
tokio::task::spawn(Tags::update());
tokio::task::spawn(Profiles::update_projects());
tokio::task::spawn(Profiles::update_modrinth_versions());
tokio::task::spawn(CredentialsStore::update_creds());
tokio::task::spawn(async { tokio::task::spawn(async {
if let Ok(state) = crate::State::get().await { if let Ok(state) = crate::State::get().await {
if !*state.offline.read().await { if !*state.offline.read().await {
@ -230,8 +247,9 @@ impl State {
let res3 = Metadata::update(); let res3 = Metadata::update();
let res4 = Profiles::update_projects(); let res4 = Profiles::update_projects();
let res5 = Settings::update_java(); let res5 = Settings::update_java();
let res6 = CredentialsStore::update_creds();
let _ = join!(res1, res2, res3, res4, res5); let _ = join!(res1, res2, res3, res4, res5, res6);
} }
} }
}); });

View File

@ -0,0 +1,398 @@
use crate::config::MODRINTH_API_URL;
use crate::state::DirectoryInfo;
use crate::util::fetch::{
fetch_advanced, read_json, write, FetchSemaphore, IoSemaphore,
};
use crate::State;
use chrono::{DateTime, Duration, Utc};
use futures::TryStreamExt;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
const AUTH_JSON: &str = "auth.json";
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ModrinthUser {
pub id: String,
pub username: String,
pub name: Option<String>,
pub avatar_url: Option<String>,
pub bio: Option<String>,
pub created: DateTime<Utc>,
pub role: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ModrinthCredentials {
pub session: String,
pub expires_at: DateTime<Utc>,
pub user: ModrinthUser,
}
#[derive(Serialize)]
#[serde(tag = "type")]
#[serde(rename_all = "snake_case")]
pub enum ModrinthCredentialsResult {
TwoFactorRequired { flow: String },
Credentials(ModrinthCredentials),
}
#[derive(Debug)]
pub struct CredentialsStore(pub Option<ModrinthCredentials>);
impl CredentialsStore {
pub async fn init(
dirs: &DirectoryInfo,
io_semaphore: &IoSemaphore,
) -> crate::Result<Self> {
let auth_path = dirs.caches_meta_dir().await.join(AUTH_JSON);
let user = read_json(&auth_path, io_semaphore).await.ok();
if let Some(user) = user {
Ok(Self(Some(user)))
} else {
Ok(Self(None))
}
}
pub async fn save(&self) -> crate::Result<()> {
let state = State::get().await?;
let auth_path =
state.directories.caches_meta_dir().await.join(AUTH_JSON);
if let Some(creds) = &self.0 {
write(&auth_path, &serde_json::to_vec(creds)?, &state.io_semaphore)
.await?;
}
Ok(())
}
pub async fn login(
&mut self,
credentials: ModrinthCredentials,
) -> crate::Result<&Self> {
self.0 = Some(credentials);
self.save().await?;
Ok(self)
}
#[tracing::instrument]
pub async fn update_creds() {
let res = async {
let state = State::get().await?;
let mut creds_write = state.credentials.write().await;
refresh_credentials(&mut creds_write, &state.fetch_semaphore)
.await?;
Ok::<(), crate::Error>(())
}
.await;
match res {
Ok(()) => {}
Err(err) => {
tracing::warn!("Unable to update credentials: {err}")
}
};
}
pub async fn logout(&mut self) -> crate::Result<&Self> {
self.0 = None;
self.save().await?;
Ok(self)
}
}
pub struct ModrinthAuthFlow {
socket: async_tungstenite::WebSocketStream<
async_tungstenite::tokio::ConnectStream,
>,
}
impl ModrinthAuthFlow {
pub async fn new(provider: &str) -> crate::Result<Self> {
let (socket, _) = async_tungstenite::tokio::connect_async(format!(
"wss://api.modrinth.com/v2/auth/ws?provider={provider}"
))
.await?;
Ok(Self { socket })
}
pub async fn prepare_login_url(&mut self) -> crate::Result<String> {
let code_resp = self
.socket
.try_next()
.await?
.ok_or(
crate::ErrorKind::WSClosedError(String::from(
"login socket URL",
))
.as_error(),
)?
.into_data();
#[derive(Deserialize)]
struct Url {
url: String,
}
let response = serde_json::from_slice::<Url>(&code_resp)?;
Ok(response.url)
}
pub async fn extract_credentials(
&mut self,
semaphore: &FetchSemaphore,
) -> crate::Result<ModrinthCredentialsResult> {
// Minecraft bearer token
let token_resp = self
.socket
.try_next()
.await?
.ok_or(
crate::ErrorKind::WSClosedError(String::from(
"login socket URL",
))
.as_error(),
)?
.into_data();
let response =
serde_json::from_slice::<HashMap<String, Value>>(&token_resp)?;
get_result_from_res("code", response, semaphore).await
}
pub async fn close(&mut self) -> crate::Result<()> {
self.socket.close(None).await?;
Ok(())
}
}
async fn get_result_from_res(
code_key: &str,
response: HashMap<String, Value>,
semaphore: &FetchSemaphore,
) -> crate::Result<ModrinthCredentialsResult> {
if let Some(flow) = response.get("flow").and_then(|x| x.as_str()) {
Ok(ModrinthCredentialsResult::TwoFactorRequired {
flow: flow.to_string(),
})
} else if let Some(code) = response.get(code_key).and_then(|x| x.as_str()) {
let info = fetch_info(code, semaphore).await?;
Ok(ModrinthCredentialsResult::Credentials(
ModrinthCredentials {
session: code.to_string(),
expires_at: Utc::now() + Duration::weeks(2),
user: info,
},
))
} else if let Some(error) =
response.get("description").and_then(|x| x.as_str())
{
Err(crate::ErrorKind::OtherError(format!(
"Failed to login with error {error}"
))
.as_error())
} else {
Err(crate::ErrorKind::OtherError(String::from(
"Flow/code/error not found in response!",
))
.as_error())
}
}
#[derive(Deserialize)]
struct Session {
session: String,
}
pub async fn login_password(
username: &str,
password: &str,
challenge: &str,
semaphore: &FetchSemaphore,
) -> crate::Result<ModrinthCredentialsResult> {
let resp = fetch_advanced(
Method::POST,
&format!("https://{MODRINTH_API_URL}auth/login"),
None,
Some(serde_json::json!({
"username": username,
"password": password,
"challenge": challenge,
})),
None,
None,
semaphore,
&CredentialsStore(None),
)
.await?;
let value = serde_json::from_slice::<HashMap<String, Value>>(&resp)?;
get_result_from_res("session", value, semaphore).await
}
async fn get_creds_from_res(
response: HashMap<String, Value>,
semaphore: &FetchSemaphore,
) -> crate::Result<ModrinthCredentials> {
if let Some(code) = response.get("session").and_then(|x| x.as_str()) {
let info = fetch_info(code, semaphore).await?;
Ok(ModrinthCredentials {
session: code.to_string(),
expires_at: Utc::now() + Duration::weeks(2),
user: info,
})
} else if let Some(error) =
response.get("description").and_then(|x| x.as_str())
{
Err(crate::ErrorKind::OtherError(format!(
"Failed to login with error {error}"
))
.as_error())
} else {
Err(crate::ErrorKind::OtherError(String::from(
"Flow/code/error not found in response!",
))
.as_error())
}
}
pub async fn login_2fa(
code: &str,
flow: &str,
semaphore: &FetchSemaphore,
) -> crate::Result<ModrinthCredentials> {
let resp = fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL}auth/login/2fa"),
None,
Some(serde_json::json!({
"code": code,
"flow": flow,
})),
None,
None,
semaphore,
&CredentialsStore(None),
)
.await?;
let response = serde_json::from_slice::<HashMap<String, Value>>(&resp)?;
get_creds_from_res(response, semaphore).await
}
pub async fn create_account(
username: &str,
email: &str,
password: &str,
challenge: &str,
sign_up_newsletter: bool,
semaphore: &FetchSemaphore,
) -> crate::Result<ModrinthCredentials> {
let resp = fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL}auth/create"),
None,
Some(serde_json::json!({
"username": username,
"email": email,
"password": password,
"challenge": challenge,
"sign_up_newsletter": sign_up_newsletter,
})),
None,
None,
semaphore,
&CredentialsStore(None),
)
.await?;
let response = serde_json::from_slice::<HashMap<String, Value>>(&resp)?;
get_creds_from_res(response, semaphore).await
}
pub async fn login_minecraft(
flow: &str,
semaphore: &FetchSemaphore,
) -> crate::Result<ModrinthCredentialsResult> {
let resp = fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL}auth/login/minecraft"),
None,
Some(serde_json::json!({
"flow": flow,
})),
None,
None,
semaphore,
&CredentialsStore(None),
)
.await?;
let response = serde_json::from_slice::<HashMap<String, Value>>(&resp)?;
get_result_from_res("session", response, semaphore).await
}
pub async fn refresh_credentials(
credentials_store: &mut CredentialsStore,
semaphore: &FetchSemaphore,
) -> crate::Result<()> {
if let Some(ref mut credentials) = credentials_store.0 {
let token = &credentials.session;
let resp = fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL}session/refresh"),
None,
None,
Some(("Authorization", token)),
None,
semaphore,
&CredentialsStore(None),
)
.await
.ok()
.and_then(|resp| serde_json::from_slice::<Session>(&resp).ok());
if let Some(value) = resp {
credentials.user = fetch_info(&value.session, semaphore).await?;
credentials.session = value.session;
credentials.expires_at = Utc::now() + Duration::weeks(2);
} else if credentials.expires_at < Utc::now() {
credentials_store.0 = None;
}
}
Ok(())
}
async fn fetch_info(
token: &str,
semaphore: &FetchSemaphore,
) -> crate::Result<ModrinthUser> {
let result = fetch_advanced(
Method::GET,
&format!("{MODRINTH_API_URL}user"),
None,
None,
Some(("Authorization", token)),
None,
semaphore,
&CredentialsStore(None),
)
.await?;
let value = serde_json::from_slice(&result)?;
Ok(value)
}

View File

@ -342,14 +342,17 @@ impl Profile {
let paths = profile.get_profile_full_project_paths().await?; let paths = profile.get_profile_full_project_paths().await?;
let caches_dir = state.directories.caches_dir(); let caches_dir = state.directories.caches_dir();
let creds = state.credentials.read().await;
let projects = crate::state::infer_data_from_files( let projects = crate::state::infer_data_from_files(
profile.clone(), profile.clone(),
paths, paths,
caches_dir, caches_dir,
&state.io_semaphore, &state.io_semaphore,
&state.fetch_semaphore, &state.fetch_semaphore,
&creds,
) )
.await?; .await?;
drop(creds);
let mut new_profiles = state.profiles.write().await; let mut new_profiles = state.profiles.write().await;
if let Some(profile) = new_profiles.0.get_mut(&profile_path_id) { if let Some(profile) = new_profiles.0.get_mut(&profile_path_id) {
@ -462,14 +465,17 @@ impl Profile {
version_id: String, version_id: String,
) -> crate::Result<(ProjectPathId, ModrinthVersion)> { ) -> crate::Result<(ProjectPathId, ModrinthVersion)> {
let state = State::get().await?; let state = State::get().await?;
let creds = state.credentials.read().await;
let version = fetch_json::<ModrinthVersion>( let version = fetch_json::<ModrinthVersion>(
Method::GET, Method::GET,
&format!("{MODRINTH_API_URL}version/{version_id}"), &format!("{MODRINTH_API_URL}version/{version_id}"),
None, None,
None, None,
&state.fetch_semaphore, &state.fetch_semaphore,
&creds,
) )
.await?; .await?;
drop(creds);
let file = if let Some(file) = version.files.iter().find(|x| x.primary) let file = if let Some(file) = version.files.iter().find(|x| x.primary)
{ {
file file
@ -482,12 +488,15 @@ impl Profile {
.into()); .into());
}; };
let creds = state.credentials.read().await;
let bytes = fetch( let bytes = fetch(
&file.url, &file.url,
file.hashes.get("sha1").map(|x| &**x), file.hashes.get("sha1").map(|x| &**x),
&state.fetch_semaphore, &state.fetch_semaphore,
&creds,
) )
.await?; .await?;
drop(creds);
let path = self let path = self
.add_project_bytes( .add_project_bytes(
&file.filename, &file.filename,
@ -736,14 +745,17 @@ impl Profiles {
future::try_join_all(files.into_iter().map( future::try_join_all(files.into_iter().map(
|(profile, files)| async { |(profile, files)| async {
let profile_name = profile.profile_id(); let profile_name = profile.profile_id();
let creds = state.credentials.read().await;
let inferred = super::projects::infer_data_from_files( let inferred = super::projects::infer_data_from_files(
profile, profile,
files, files,
caches_dir.clone(), caches_dir.clone(),
&state.io_semaphore, &state.io_semaphore,
&state.fetch_semaphore, &state.fetch_semaphore,
&creds,
) )
.await?; .await?;
drop(creds);
let mut new_profiles = state.profiles.write().await; let mut new_profiles = state.profiles.write().await;
if let Some(profile) = new_profiles.0.get_mut(&profile_name) if let Some(profile) = new_profiles.0.get_mut(&profile_name)
@ -803,6 +815,7 @@ impl Profiles {
let linked_project = linked_project; let linked_project = linked_project;
let state = state.clone(); let state = state.clone();
async move { async move {
let creds = state.credentials.read().await;
let versions: Vec<ModrinthVersion> = fetch_json( let versions: Vec<ModrinthVersion> = fetch_json(
Method::GET, Method::GET,
&format!( &format!(
@ -813,8 +826,10 @@ impl Profiles {
None, None,
None, None,
&state.fetch_semaphore, &state.fetch_semaphore,
&creds,
) )
.await?; .await?;
drop(creds);
// Versions are pre-sorted in labrinth (by versions.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published));) // 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

View File

@ -1,7 +1,7 @@
//! Project management + inference //! Project management + inference
use crate::config::MODRINTH_API_URL; use crate::config::MODRINTH_API_URL;
use crate::state::Profile; use crate::state::{CredentialsStore, ModrinthUser, Profile};
use crate::util::fetch::{ use crate::util::fetch::{
fetch_json, write_cached_icon, FetchSemaphore, IoSemaphore, fetch_json, write_cached_icon, FetchSemaphore, IoSemaphore,
}; };
@ -168,18 +168,6 @@ pub struct ModrinthTeamMember {
pub ordering: i64, pub ordering: i64,
} }
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ModrinthUser {
pub id: String,
pub github_id: Option<u64>,
pub username: String,
pub name: Option<String>,
pub avatar_url: Option<String>,
pub bio: Option<String>,
pub created: DateTime<Utc>,
pub role: String,
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug)] #[derive(Serialize, Deserialize, Copy, Clone, Debug)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum DependencyType { pub enum DependencyType {
@ -289,6 +277,7 @@ pub async fn infer_data_from_files(
cache_dir: PathBuf, cache_dir: PathBuf,
io_semaphore: &IoSemaphore, io_semaphore: &IoSemaphore,
fetch_semaphore: &FetchSemaphore, fetch_semaphore: &FetchSemaphore,
credentials: &CredentialsStore,
) -> crate::Result<HashMap<ProjectPathId, Project>> { ) -> crate::Result<HashMap<ProjectPathId, Project>> {
let mut file_path_hashes = HashMap::new(); let mut file_path_hashes = HashMap::new();
@ -327,6 +316,7 @@ pub async fn infer_data_from_files(
"algorithm": "sha512", "algorithm": "sha512",
})), })),
fetch_semaphore, fetch_semaphore,
credentials,
), ),
fetch_json::<HashMap<String, ModrinthVersion>>( fetch_json::<HashMap<String, ModrinthVersion>>(
Method::POST, Method::POST,
@ -339,6 +329,7 @@ pub async fn infer_data_from_files(
"game_versions": [profile.metadata.game_version] "game_versions": [profile.metadata.game_version]
})), })),
fetch_semaphore, fetch_semaphore,
credentials,
) )
)?; )?;
@ -357,6 +348,7 @@ pub async fn infer_data_from_files(
None, None,
None, None,
fetch_semaphore, fetch_semaphore,
credentials,
) )
.await?; .await?;
@ -374,6 +366,7 @@ pub async fn infer_data_from_files(
None, None,
None, None,
fetch_semaphore, fetch_semaphore,
credentials,
) )
.await? .await?
.into_iter() .into_iter()

View File

@ -41,7 +41,7 @@ pub struct Settings {
#[serde(default)] #[serde(default)]
pub advanced_rendering: bool, pub advanced_rendering: bool,
#[serde(default)] #[serde(default)]
pub onboarded_new: bool, pub fully_onboarded: bool,
#[serde(default = "DirectoryInfo::get_initial_settings_dir")] #[serde(default = "DirectoryInfo::get_initial_settings_dir")]
pub loaded_config_dir: Option<PathBuf>, pub loaded_config_dir: Option<PathBuf>,
} }
@ -82,7 +82,7 @@ impl Settings {
developer_mode: false, developer_mode: false,
opt_out_analytics: false, opt_out_analytics: false,
advanced_rendering: true, advanced_rendering: true,
onboarded_new: false, fully_onboarded: false,
// By default, the config directory is the same as the settings directory // By default, the config directory is the same as the settings directory
loaded_config_dir: DirectoryInfo::get_initial_settings_dir(), loaded_config_dir: DirectoryInfo::get_initial_settings_dir(),

View File

@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::config::MODRINTH_API_URL; use crate::config::MODRINTH_API_URL;
use crate::data::DirectoryInfo; use crate::data::DirectoryInfo;
use crate::state::CredentialsStore;
use crate::util::fetch::{ use crate::util::fetch::{
fetch_json, read_json, write, FetchSemaphore, IoSemaphore, fetch_json, read_json, write, FetchSemaphore, IoSemaphore,
}; };
@ -27,6 +28,7 @@ impl Tags {
fetch_online: bool, fetch_online: bool,
io_semaphore: &IoSemaphore, io_semaphore: &IoSemaphore,
fetch_semaphore: &FetchSemaphore, fetch_semaphore: &FetchSemaphore,
credentials: &CredentialsStore,
) -> crate::Result<Self> { ) -> crate::Result<Self> {
let mut tags = None; let mut tags = None;
let tags_path = dirs.caches_meta_dir().await.join("tags.json"); let tags_path = dirs.caches_meta_dir().await.join("tags.json");
@ -35,7 +37,7 @@ impl Tags {
{ {
tags = Some(tags_json); tags = Some(tags_json);
} else if fetch_online { } else if fetch_online {
match Self::fetch(fetch_semaphore).await { match Self::fetch(fetch_semaphore, credentials).await {
Ok(tags_fetch) => tags = Some(tags_fetch), Ok(tags_fetch) => tags = Some(tags_fetch),
Err(err) => { Err(err) => {
tracing::warn!("Unable to fetch launcher tags: {err}") tracing::warn!("Unable to fetch launcher tags: {err}")
@ -58,7 +60,11 @@ impl Tags {
pub async fn update() { pub async fn update() {
let res = async { let res = async {
let state = crate::State::get().await?; let state = crate::State::get().await?;
let tags_fetch = Tags::fetch(&state.fetch_semaphore).await?;
let creds = state.credentials.read().await;
let tags_fetch =
Tags::fetch(&state.fetch_semaphore, &creds).await?;
drop(creds);
let tags_path = let tags_path =
state.directories.caches_meta_dir().await.join("tags.json"); state.directories.caches_meta_dir().await.join("tags.json");
@ -123,7 +129,10 @@ impl Tags {
} }
// Fetches the tags from the Modrinth API and stores them in the database // Fetches the tags from the Modrinth API and stores them in the database
pub async fn fetch(semaphore: &FetchSemaphore) -> crate::Result<Self> { pub async fn fetch(
semaphore: &FetchSemaphore,
credentials: &CredentialsStore,
) -> crate::Result<Self> {
let categories = format!("{MODRINTH_API_URL}tag/category"); let categories = format!("{MODRINTH_API_URL}tag/category");
let loaders = format!("{MODRINTH_API_URL}tag/loader"); let loaders = format!("{MODRINTH_API_URL}tag/loader");
let game_versions = format!("{MODRINTH_API_URL}tag/game_version"); let game_versions = format!("{MODRINTH_API_URL}tag/game_version");
@ -137,6 +146,7 @@ impl Tags {
None, None,
None, None,
semaphore, semaphore,
credentials,
); );
let loaders_fut = fetch_json::<Vec<Loader>>( let loaders_fut = fetch_json::<Vec<Loader>>(
Method::GET, Method::GET,
@ -144,6 +154,7 @@ impl Tags {
None, None,
None, None,
semaphore, semaphore,
credentials,
); );
let game_versions_fut = fetch_json::<Vec<GameVersion>>( let game_versions_fut = fetch_json::<Vec<GameVersion>>(
Method::GET, Method::GET,
@ -151,6 +162,7 @@ impl Tags {
None, None,
None, None,
semaphore, semaphore,
credentials,
); );
let donation_platforms_fut = fetch_json::<Vec<DonationPlatform>>( let donation_platforms_fut = fetch_json::<Vec<DonationPlatform>>(
Method::GET, Method::GET,
@ -158,6 +170,7 @@ impl Tags {
None, None,
None, None,
semaphore, semaphore,
credentials,
); );
let report_types_fut = fetch_json::<Vec<String>>( let report_types_fut = fetch_json::<Vec<String>>(
Method::GET, Method::GET,
@ -165,6 +178,7 @@ impl Tags {
None, None,
None, None,
semaphore, semaphore,
credentials,
); );
let ( let (

View File

@ -1,6 +1,7 @@
//! Functions for fetching infromation from the Internet //! Functions for fetching infromation from the Internet
use crate::event::emit::emit_loading; use crate::event::emit::emit_loading;
use crate::event::LoadingBarId; use crate::event::LoadingBarId;
use crate::state::CredentialsStore;
use bytes::Bytes; use bytes::Bytes;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use reqwest::Method; use reqwest::Method;
@ -41,8 +42,19 @@ pub async fn fetch(
url: &str, url: &str,
sha1: Option<&str>, sha1: Option<&str>,
semaphore: &FetchSemaphore, semaphore: &FetchSemaphore,
credentials: &CredentialsStore,
) -> crate::Result<Bytes> { ) -> crate::Result<Bytes> {
fetch_advanced(Method::GET, url, sha1, None, None, None, semaphore).await fetch_advanced(
Method::GET,
url,
sha1,
None,
None,
None,
semaphore,
credentials,
)
.await
} }
#[tracing::instrument(skip(json_body, semaphore))] #[tracing::instrument(skip(json_body, semaphore))]
@ -52,13 +64,22 @@ pub async fn fetch_json<T>(
sha1: Option<&str>, sha1: Option<&str>,
json_body: Option<serde_json::Value>, json_body: Option<serde_json::Value>,
semaphore: &FetchSemaphore, semaphore: &FetchSemaphore,
credentials: &CredentialsStore,
) -> crate::Result<T> ) -> crate::Result<T>
where where
T: DeserializeOwned, T: DeserializeOwned,
{ {
let result = let result = fetch_advanced(
fetch_advanced(method, url, sha1, json_body, None, None, semaphore) method,
.await?; url,
sha1,
json_body,
None,
None,
semaphore,
credentials,
)
.await?;
let value = serde_json::from_slice(&result)?; let value = serde_json::from_slice(&result)?;
Ok(value) Ok(value)
} }
@ -66,6 +87,7 @@ where
/// Downloads a file with retry and checksum functionality /// Downloads a file with retry and checksum functionality
#[tracing::instrument(skip(json_body, semaphore))] #[tracing::instrument(skip(json_body, semaphore))]
#[theseus_macros::debug_pin] #[theseus_macros::debug_pin]
#[allow(clippy::too_many_arguments)]
pub async fn fetch_advanced( pub async fn fetch_advanced(
method: Method, method: Method,
url: &str, url: &str,
@ -74,6 +96,7 @@ pub async fn fetch_advanced(
header: Option<(&str, &str)>, header: Option<(&str, &str)>,
loading_bar: Option<(&LoadingBarId, f64)>, loading_bar: Option<(&LoadingBarId, f64)>,
semaphore: &FetchSemaphore, semaphore: &FetchSemaphore,
credentials: &CredentialsStore,
) -> crate::Result<Bytes> { ) -> crate::Result<Bytes> {
let io_semaphore = semaphore.0.read().await; let io_semaphore = semaphore.0.read().await;
let _permit = io_semaphore.acquire().await?; let _permit = io_semaphore.acquire().await?;
@ -89,6 +112,12 @@ pub async fn fetch_advanced(
req = req.header(header.0, header.1); req = req.header(header.0, header.1);
} }
if url.starts_with("https://cdn.modrinth.com") {
if let Some(creds) = &credentials.0 {
req = req.header("Authorization", &creds.session);
}
}
let result = req.send().await; let result = req.send().await;
match result { match result {
Ok(x) => { Ok(x) => {
@ -163,6 +192,7 @@ pub async fn fetch_mirrors(
mirrors: &[&str], mirrors: &[&str],
sha1: Option<&str>, sha1: Option<&str>,
semaphore: &FetchSemaphore, semaphore: &FetchSemaphore,
credentials: &CredentialsStore,
) -> crate::Result<Bytes> { ) -> crate::Result<Bytes> {
if mirrors.is_empty() { if mirrors.is_empty() {
return Err(crate::ErrorKind::InputError( return Err(crate::ErrorKind::InputError(
@ -172,7 +202,7 @@ pub async fn fetch_mirrors(
} }
for (index, mirror) in mirrors.iter().enumerate() { for (index, mirror) in mirrors.iter().enumerate() {
let result = fetch(mirror, sha1, semaphore).await; let result = fetch(mirror, sha1, semaphore, credentials).await;
if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) { if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) {
return result; return result;
@ -186,7 +216,12 @@ pub async fn fetch_mirrors(
#[tracing::instrument(skip(semaphore))] #[tracing::instrument(skip(semaphore))]
#[theseus_macros::debug_pin] #[theseus_macros::debug_pin]
pub async fn check_internet(semaphore: &FetchSemaphore, timeout: u64) -> bool { pub async fn check_internet(semaphore: &FetchSemaphore, timeout: u64) -> bool {
let result = fetch("https://api.modrinth.com", None, semaphore); let result = fetch(
"https://api.modrinth.com",
None,
semaphore,
&CredentialsStore(None),
);
let result = let result =
tokio::time::timeout(Duration::from_secs(timeout), result).await; tokio::time::timeout(Duration::from_secs(timeout), result).await;
matches!(result, Ok(Ok(_))) matches!(result, Ok(Ok(_)))

View File

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

View File

@ -52,7 +52,7 @@ impl UserAdd {
let credentials = flow.await??; let credentials = flow.await??;
State::sync().await?; State::sync().await?;
success!("Logged in user {}.", credentials.username); success!("Logged in user {}.", credentials.0.username);
Ok(()) Ok(())
} }
} }

View File

@ -1,7 +1,7 @@
{ {
"name": "theseus_gui", "name": "theseus_gui",
"private": true, "private": true,
"version": "0.3.1", "version": "0.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -1,4 +1,8 @@
lockfileVersion: '6.0' lockfileVersion: '6.1'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies: dependencies:
'@tauri-apps/api': '@tauri-apps/api':

View File

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

View File

@ -28,7 +28,8 @@ pub async fn auth_authenticate_begin_flow() -> Result<url::Url> {
/// This completes the authentication flow quasi-synchronously, returning the sign-in credentials /// This completes the authentication flow quasi-synchronously, returning the sign-in credentials
/// (and also adding the credentials to the state) /// (and also adding the credentials to the state)
#[tauri::command] #[tauri::command]
pub async fn auth_authenticate_await_completion() -> Result<Credentials> { pub async fn auth_authenticate_await_completion(
) -> Result<(Credentials, Option<String>)> {
Ok(auth::authenticate_await_complete_flow().await?) Ok(auth::authenticate_await_complete_flow().await?)
} }

View File

@ -7,6 +7,7 @@ pub mod import;
pub mod jre; pub mod jre;
pub mod logs; pub mod logs;
pub mod metadata; pub mod metadata;
pub mod mr_auth;
pub mod pack; pub mod pack;
pub mod process; pub mod process;
pub mod profile; pub mod profile;

View File

@ -0,0 +1,88 @@
use crate::api::Result;
use tauri::plugin::TauriPlugin;
use theseus::prelude::*;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("mr_auth")
.invoke_handler(tauri::generate_handler![
authenticate_begin_flow,
authenticate_await_completion,
cancel_flow,
login_pass,
login_2fa,
login_minecraft,
create_account,
refresh,
logout,
get,
])
.build()
}
#[tauri::command]
pub async fn authenticate_begin_flow(provider: &str) -> Result<String> {
Ok(theseus::mr_auth::authenticate_begin_flow(provider).await?)
}
#[tauri::command]
pub async fn authenticate_await_completion() -> Result<ModrinthCredentialsResult>
{
Ok(theseus::mr_auth::authenticate_await_complete_flow().await?)
}
#[tauri::command]
pub async fn cancel_flow() -> Result<()> {
Ok(theseus::mr_auth::cancel_flow().await?)
}
#[tauri::command]
pub async fn login_pass(
username: &str,
password: &str,
challenge: &str,
) -> Result<ModrinthCredentialsResult> {
Ok(theseus::mr_auth::login_password(username, password, challenge).await?)
}
#[tauri::command]
pub async fn login_2fa(code: &str, flow: &str) -> Result<ModrinthCredentials> {
Ok(theseus::mr_auth::login_2fa(code, flow).await?)
}
#[tauri::command]
pub async fn login_minecraft(flow: &str) -> Result<ModrinthCredentialsResult> {
Ok(theseus::mr_auth::login_minecraft(flow).await?)
}
#[tauri::command]
pub async fn create_account(
username: &str,
email: &str,
password: &str,
challenge: &str,
sign_up_newsletter: bool,
) -> Result<ModrinthCredentials> {
Ok(theseus::mr_auth::create_account(
username,
email,
password,
challenge,
sign_up_newsletter,
)
.await?)
}
#[tauri::command]
pub async fn refresh() -> Result<()> {
Ok(theseus::mr_auth::refresh().await?)
}
#[tauri::command]
pub async fn logout() -> Result<()> {
Ok(theseus::mr_auth::logout().await?)
}
#[tauri::command]
pub async fn get() -> Result<Option<ModrinthCredentials>> {
Ok(theseus::mr_auth::get_credentials().await?)
}

View File

@ -127,6 +127,7 @@ fn main() {
} }
let builder = builder let builder = builder
.plugin(api::auth::init()) .plugin(api::auth::init())
.plugin(api::mr_auth::init())
.plugin(api::import::init()) .plugin(api::import::init())
.plugin(api::logs::init()) .plugin(api::logs::init())
.plugin(api::jre::init()) .plugin(api::jre::init())

View File

@ -8,7 +8,7 @@
}, },
"package": { "package": {
"productName": "Modrinth App", "productName": "Modrinth App",
"version": "0.3.1" "version": "0.4.0"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {

View File

@ -54,14 +54,14 @@ const onboardingVideo = ref()
defineExpose({ defineExpose({
initialize: async () => { initialize: async () => {
isLoading.value = false isLoading.value = false
const { theme, opt_out_analytics, collapsed_navigation, advanced_rendering, onboarded_new } = const { theme, opt_out_analytics, collapsed_navigation, advanced_rendering, fully_onboarded } =
await get() await get()
const os = await getOS() const os = await getOS()
// video should play if the user is not on linux, and has not onboarded // video should play if the user is not on linux, and has not onboarded
videoPlaying.value = !onboarded_new && os !== 'Linux' videoPlaying.value = !fully_onboarded && os !== 'Linux'
const dev = await isDev() const dev = await isDev()
const version = await getVersion() const version = await getVersion()
showOnboarding.value = !onboarded_new showOnboarding.value = !fully_onboarded
themeStore.setThemeState(theme) themeStore.setThemeState(theme)
themeStore.collapsedNavigation = collapsed_navigation themeStore.collapsedNavigation = collapsed_navigation
@ -71,7 +71,7 @@ defineExpose({
if (opt_out_analytics) { if (opt_out_analytics) {
mixpanel_opt_out_tracking() mixpanel_opt_out_tracking()
} }
mixpanel_track('Launched', { version, dev, onboarded_new }) mixpanel_track('Launched', { version, dev, fully_onboarded })
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault()) if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())

View File

@ -78,6 +78,7 @@ import {
import { get, set } from '@/helpers/settings' import { get, set } from '@/helpers/settings'
import { WebviewWindow } from '@tauri-apps/api/window' import { WebviewWindow } from '@tauri-apps/api/window'
import { handleError } from '@/store/state.js' import { handleError } from '@/store/state.js'
import { get as getCreds, login_minecraft } from '@/helpers/mr_auth'
import { mixpanel_track } from '@/helpers/mixpanel' import { mixpanel_track } from '@/helpers/mixpanel'
defineProps({ defineProps({
@ -124,8 +125,20 @@ async function login() {
}) })
const loggedIn = await authenticate_await_completion().catch(handleError) const loggedIn = await authenticate_await_completion().catch(handleError)
await setAccount(loggedIn)
await refreshValues() if (loggedIn && loggedIn[0]) {
await setAccount(loggedIn[0])
await refreshValues()
const creds = await getCreds().catch(handleError)
if (!creds) {
try {
await login_minecraft(loggedIn[1])
} catch (err) {
/* empty */
}
}
}
await window.close() await window.close()
mixpanel_track('AccountLogIn') mixpanel_track('AccountLogIn')
} }

View File

@ -35,9 +35,10 @@ async function login() {
const loggedIn = await authenticate_await_completion().catch(handleError) const loggedIn = await authenticate_await_completion().catch(handleError)
loginModal.value.hide() loginModal.value.hide()
props.nextPage()
props.nextPage(loggedIn[1])
const settings = await get().catch(handleError) const settings = await get().catch(handleError)
settings.default_user = loggedIn.id settings.default_user = loggedIn[0].id
await set(settings).catch(handleError) await set(settings).catch(handleError)
await mixpanel.track('AccountLogIn') await mixpanel.track('AccountLogIn')
} }
@ -83,7 +84,7 @@ const openUrl = async () => {
Browser didn't open? Browser didn't open?
</Button> </Button>
</div> </div>
<Button class="transparent" large @click="nextPage"> Next </Button> <Button class="transparent" large @click="nextPage()"> Next </Button>
</div> </div>
</Card> </Card>
</div> </div>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { Button, Card, UserIcon, LockIcon } from 'omorphia' import { Button, Card, UserIcon, LockIcon, MailIcon, Checkbox } from 'omorphia'
import { import {
DiscordIcon, DiscordIcon,
GithubIcon, GithubIcon,
@ -8,8 +8,19 @@ import {
SteamIcon, SteamIcon,
GitLabIcon, GitLabIcon,
} from '@/assets/external' } from '@/assets/external'
import {
authenticate_begin_flow,
authenticate_await_completion,
login_2fa,
create_account,
login_pass,
get as getCreds,
login_minecraft,
} from '@/helpers/mr_auth.js'
import { handleError, useNotifications } from '@/store/state.js'
import { onMounted, ref } from 'vue'
defineProps({ const props = defineProps({
nextPage: { nextPage: {
type: Function, type: Function,
required: true, required: true,
@ -18,58 +29,201 @@ defineProps({
type: Function, type: Function,
required: true, required: true,
}, },
modal: {
type: Boolean,
required: true,
},
flow: {
type: String,
default: null,
},
})
const loggingIn = ref(true)
const twoFactorFlow = ref(null)
const twoFactorCode = ref('')
const email = ref('')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const subscribe = ref(true)
async function signInOauth(provider) {
const url = await authenticate_begin_flow(provider).catch(handleError)
await window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: url,
},
})
const creds = await authenticate_await_completion().catch(handleError)
if (creds && creds.type === 'two_factor_required') {
twoFactorFlow.value = creds.flow
} else if (creds && creds.session) {
props.nextPage()
}
}
async function signIn2fa() {
const creds = await login_2fa(twoFactorCode.value, twoFactorFlow.value).catch(handleError)
if (creds && creds.session) {
props.nextPage()
}
}
async function signIn() {
const creds = await login_pass(
username.value,
password.value,
window.turnstile.getResponse()
).catch(handleError)
window.turnstile.reset()
if (creds && creds.type === 'two_factor_required') {
twoFactorFlow.value = creds.flow
} else if (creds && creds.session) {
props.nextPage()
}
}
async function createAccount() {
if (password.value !== confirmPassword.value) {
const notifs = useNotifications()
notifs.addNotification({
title: 'An error occurred',
text: 'Passwords do not match!',
type: 'error',
})
return
}
const creds = await create_account(
username.value,
email.value,
password.value,
window.turnstile.getResponse(),
subscribe.value
).catch(handleError)
window.turnstile.reset()
if (creds && creds.session) {
props.nextPage()
}
}
async function goToNextPage() {
const creds = await getCreds().catch(handleError)
if (!creds) {
try {
await login_minecraft(props.flow)
} catch {
/* empty */
}
}
props.nextPage()
}
onMounted(() => {
if (window.turnstile === null || !window.turnstile) {
const script = document.createElement('script')
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
script.async = true
script.defer = true
document.head.appendChild(script)
}
}) })
</script> </script>
<template> <template>
<Card> <Card>
<h1>Login to Modrinth</h1> <div class="cf-turnstile" data-sitekey="0x4AAAAAAAHWfmKCm7cUG869"></div>
<div class="button-grid"> <template v-if="twoFactorFlow">
<Button class="discord" large> <h1>Enter two-factor code</h1>
<DiscordIcon /> <p>Please enter a two-factor code to proceed.</p>
Discord <input v-model="twoFactorCode" maxlength="11" type="text" placeholder="Enter code..." />
</Button> </template>
<Button class="github" large> <template v-else>
<GithubIcon /> <h1 v-if="loggingIn">Login to Modrinth</h1>
Github <h1 v-else>Create an account</h1>
</Button> <div class="button-grid">
<Button class="white" large> <Button class="discord" large @click="signInOauth('discord')">
<MicrosoftIcon /> <DiscordIcon />
Microsoft Discord
</Button> </Button>
<Button class="google" large> <Button class="github" large @click="signInOauth('github')">
<GoogleIcon /> <GithubIcon />
Google Github
</Button> </Button>
<Button class="white" large> <Button class="white" large @click="signInOauth('microsoft')">
<SteamIcon /> <MicrosoftIcon />
Steam Microsoft
</Button> </Button>
<Button class="gitlab" large> <Button class="google" large @click="signInOauth('google')">
<GitLabIcon /> <GoogleIcon />
GitLab Google
</Button> </Button>
</div> <Button class="white" large @click="signInOauth('steam')">
<div class="divider"> <SteamIcon />
<hr /> Steam
<p>Or</p> </Button>
</div> <Button class="gitlab" large @click="signInOauth('gitlab')">
<div class="iconified-input username"> <GitLabIcon />
<UserIcon /> GitLab
<input type="text" placeholder="Email or username" /> </Button>
</div> </div>
<div class="iconified-input"> <div class="divider">
<LockIcon /> <hr />
<input type="password" placeholder="Password" /> <p>Or</p>
</div> </div>
<div class="link-row"> <div v-if="!loggingIn" class="iconified-input username">
<a class="button-base"> Create account </a> <MailIcon />
<a class="button-base"> Forgot password? </a> <input v-model="email" type="text" placeholder="Email" />
</div> </div>
<div class="iconified-input username">
<UserIcon />
<input
v-model="username"
type="text"
:placeholder="loggingIn ? 'Email or username' : 'Username'"
/>
</div>
<div class="iconified-input" :class="{ username: !loggingIn }">
<LockIcon />
<input v-model="password" type="password" placeholder="Password" />
</div>
<div v-if="!loggingIn" class="iconified-input username">
<LockIcon />
<input v-model="confirmPassword" type="password" placeholder="Confirm password" />
</div>
<Checkbox
v-if="!loggingIn"
v-model="subscribe"
class="subscribe-btn"
label="Subscribe to updates about Modrinth"
/>
<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">
Forgot password?
</a>
</div>
</template>
<div class="button-row"> <div class="button-row">
<Button class="transparent" large @click="prevPage"> Back </Button> <Button class="transparent" large @click="prevPage"> {{ modal ? 'Close' : 'Back' }} </Button>
<Button color="primary" large> Login </Button> <Button v-if="twoFactorCode" color="primary" large @click="signIn2fa"> Login </Button>
<Button class="transparent" large @click="nextPage"> Next </Button> <Button v-else-if="loggingIn" color="primary" large @click="signIn"> Login </Button>
<Button v-else color="primary" large @click="createAccount"> Create account </Button>
<Button class="transparent" large @click="goToNextPage">
{{ modal ? 'Continue' : 'Next' }}
</Button>
</div> </div>
</Card> </Card>
</template> </template>
@ -179,4 +333,10 @@ defineProps({
padding: var(--gap-md) 0; padding: var(--gap-md) 0;
} }
} }
:deep {
.checkbox {
border: none;
}
}
</style> </style>

File diff suppressed because one or more lines are too long

View File

@ -32,7 +32,7 @@ import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue'
import { auto_install_java, get_jre } from '@/helpers/jre.js' import { auto_install_java, get_jre } from '@/helpers/jre.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import ImportingCard from '@/components/ui/tutorial/ImportingCard.vue' import ImportingCard from '@/components/ui/tutorial/ImportingCard.vue'
// import ModrinthLoginScreen from '@/components/ui/tutorial/ModrinthLoginScreen.vue' import ModrinthLoginScreen from '@/components/ui/tutorial/ModrinthLoginScreen.vue'
import PreImportScreen from '@/components/ui/tutorial/PreImportScreen.vue' import PreImportScreen from '@/components/ui/tutorial/PreImportScreen.vue'
const phase = ref(0) const phase = ref(0)
@ -45,6 +45,8 @@ const props = defineProps({
}, },
}) })
const flow = ref('')
const nextPhase = () => { const nextPhase = () => {
phase.value++ phase.value++
mixpanel.track('TutorialPhase', { page: phase.value }) mixpanel.track('TutorialPhase', { page: phase.value })
@ -54,9 +56,13 @@ const prevPhase = () => {
phase.value-- phase.value--
} }
const nextPage = () => { const nextPage = (newFlow) => {
page.value++ page.value++
mixpanel.track('OnboardingPage', { page: page.value }) mixpanel.track('OnboardingPage', { page: page.value })
if (newFlow) {
flow.value = newFlow
}
} }
const endOnboarding = () => { const endOnboarding = () => {
@ -70,7 +76,7 @@ const prevPage = () => {
const finishOnboarding = async () => { const finishOnboarding = async () => {
mixpanel.track('OnboardingFinish') mixpanel.track('OnboardingFinish')
const settings = await get() const settings = await get()
settings.onboarded_new = true settings.fully_onboarded = true
await set(settings) await set(settings)
props.finish() props.finish()
} }
@ -119,14 +125,20 @@ onMounted(async () => {
<Button color="primary" @click="nextPage"> Get started </Button> <Button color="primary" @click="nextPage"> Get started </Button>
</GalleryImage> </GalleryImage>
<LoginCard v-else-if="page === 2" :next-page="nextPage" :prev-page="prevPage" /> <LoginCard v-else-if="page === 2" :next-page="nextPage" :prev-page="prevPage" />
<!-- <ModrinthLoginScreen v-else-if="page === 3" :next-page="nextPage" :prev-page="prevPage" />--> <ModrinthLoginScreen
<PreImportScreen
v-else-if="page === 3" v-else-if="page === 3"
:modal="false"
:next-page="nextPage"
:prev-page="prevPage"
:flow="flow"
/>
<PreImportScreen
v-else-if="page === 4"
:next-page="endOnboarding" :next-page="endOnboarding"
:prev-page="prevPage" :prev-page="prevPage"
:import-page="nextPage" :import-page="nextPage"
/> />
<ImportingCard v-else-if="page === 4" :next-page="endOnboarding" :prev-page="prevPage" /> <ImportingCard v-else-if="page === 5" :next-page="endOnboarding" :prev-page="prevPage" />
</div> </div>
<div v-else class="container"> <div v-else class="container">
<StickyTitleBar v-if="phase === 9" /> <StickyTitleBar v-if="phase === 9" />

View File

@ -0,0 +1,50 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
export async function authenticate_begin_flow(provider) {
return await invoke('plugin:mr_auth|authenticate_begin_flow', { provider })
}
export async function authenticate_await_completion() {
return await invoke('plugin:mr_auth|authenticate_await_completion')
}
export async function cancel_flow() {
return await invoke('plugin:mr_auth|cancel_flow')
}
export async function login_pass(username, password, challenge) {
return await invoke('plugin:mr_auth|login_pass', { username, password, challenge })
}
export async function login_2fa(code, flow) {
return await invoke('plugin:mr_auth|login_2fa', { code, flow })
}
export async function login_minecraft(flow) {
return await invoke('plugin:mr_auth|login_minecraft', { flow })
}
export async function create_account(username, email, password, challenge, signUpNewsletter) {
return await invoke('plugin:mr_auth|create_account', {
username,
email,
password,
challenge,
signUpNewsletter,
})
}
export async function refresh() {
return await invoke('plugin:mr_auth|refresh')
}
export async function logout() {
return await invoke('plugin:mr_auth|logout')
}
export async function get() {
return await invoke('plugin:mr_auth|get')
}

View File

@ -1,10 +1,12 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { Card, Slider, DropdownSelect, Toggle } from 'omorphia' import { Card, Slider, DropdownSelect, Toggle, Modal, LogOutIcon, LogInIcon } from 'omorphia'
import { handleError, useTheming } from '@/store/state' import { handleError, useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings' import { get, set } from '@/helpers/settings'
import { get_max_memory } from '@/helpers/jre' 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 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 { mixpanel_opt_out_tracking, mixpanel_opt_in_tracking } from '@/helpers/mixpanel'
const pageOptions = ['Home', 'Library'] const pageOptions = ['Home', 'Library']
@ -76,10 +78,54 @@ watch(
}, },
{ deep: true } { deep: true }
) )
const credentials = ref(await getCreds().catch(handleError))
const loginScreenModal = ref()
async function logOut() {
await logout().catch(handleError)
credentials.value = await getCreds().catch(handleError)
}
async function signInAfter() {
loginScreenModal.value.hide()
credentials.value = await getCreds().catch(handleError)
}
</script> </script>
<template> <template>
<div class="settings-page"> <div class="settings-page">
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Account</span>
</h3>
</div>
<Modal
ref="loginScreenModal"
class="login-screen-modal"
:noblur="!themeStore.advancedRendering"
>
<ModrinthLoginScreen
:modal="true"
:prev-page="$refs.loginScreenModal.show()"
:next-page="signInAfter"
/>
</Modal>
<div class="adjacent-input">
<label for="theme">
<span class="label__title">Manage account</span>
<span v-if="credentials" class="label__description">
You are currently logged in as {{ credentials.user.username }}.
</span>
<span v-else> Sign in to your Modrinth account. </span>
</label>
<button v-if="credentials" class="btn" @click="logOut"><LogOutIcon /> Sign out</button>
<button v-else class="btn" @click="$refs.loginScreenModal.show()">
<LogInIcon /> Sign in
</button>
</div>
</Card>
<Card> <Card>
<div class="label"> <div class="label">
<h3> <h3>
@ -397,4 +443,16 @@ watch(
.card-divider { .card-divider {
margin: 1rem 0; margin: 1rem 0;
} }
:deep {
.login-screen-modal {
.modal-container .modal-body {
width: auto;
.content {
background: none;
}
}
}
}
</style> </style>

View File

@ -24,8 +24,8 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
let credentials = auth::authenticate_await_complete_flow().await?; let credentials = auth::authenticate_await_complete_flow().await?;
State::sync().await?; State::sync().await?;
println!("Logged in user {}.", credentials.username); println!("Logged in user {}.", credentials.0.username);
Ok(credentials) Ok(credentials.0)
} }
#[tokio::main] #[tokio::main]