From 47e28d24c81a00477afd91da8e1cdf3a79720385 Mon Sep 17 00:00:00 2001 From: Geometrically <18202329+Geometrically@users.noreply.github.com> Date: Fri, 4 Aug 2023 23:38:34 -0700 Subject: [PATCH] Launcher Auth (#450) * Launcher Auth * Finish auth * final fixes --- Cargo.lock | 6 +- theseus/Cargo.toml | 2 +- theseus/src/api/auth.rs | 22 +- theseus/src/api/jre.rs | 3 + theseus/src/api/mod.rs | 7 +- theseus/src/api/mr_auth.rs | 157 +++++++ theseus/src/api/pack/import/curseforge.rs | 10 +- theseus/src/api/pack/install_from.rs | 8 +- theseus/src/api/pack/install_mrpack.rs | 3 + theseus/src/launcher/auth.rs | 33 +- theseus/src/launcher/download.rs | 12 +- theseus/src/state/auth_task.rs | 8 +- theseus/src/state/mod.rs | 30 +- theseus/src/state/mr_auth.rs | 398 ++++++++++++++++++ theseus/src/state/profiles.rs | 15 + theseus/src/state/projects.rs | 19 +- theseus/src/state/settings.rs | 4 +- theseus/src/state/tags.rs | 20 +- theseus/src/util/fetch.rs | 47 ++- theseus_cli/Cargo.toml | 2 +- theseus_cli/src/subcommands/user.rs | 2 +- theseus_gui/package.json | 2 +- theseus_gui/pnpm-lock.yaml | 6 +- theseus_gui/src-tauri/Cargo.toml | 2 +- theseus_gui/src-tauri/src/api/auth.rs | 3 +- theseus_gui/src-tauri/src/api/mod.rs | 1 + theseus_gui/src-tauri/src/api/mr_auth.rs | 88 ++++ theseus_gui/src-tauri/src/main.rs | 1 + theseus_gui/src-tauri/tauri.conf.json | 2 +- theseus_gui/src/App.vue | 8 +- .../src/components/ui/AccountsCard.vue | 17 +- .../src/components/ui/tutorial/LoginCard.vue | 7 +- .../ui/tutorial/ModrinthLoginScreen.vue | 256 ++++++++--- .../ui/tutorial/OnboardingModal.vue | 338 --------------- .../ui/tutorial/OnboardingScreen.vue | 24 +- theseus_gui/src/helpers/mr_auth.js | 50 +++ theseus_gui/src/pages/Settings.vue | 60 ++- theseus_playground/src/main.rs | 4 +- 38 files changed, 1200 insertions(+), 477 deletions(-) create mode 100644 theseus/src/api/mr_auth.rs create mode 100644 theseus/src/state/mr_auth.rs create mode 100644 theseus_gui/src-tauri/src/api/mr_auth.rs delete mode 100644 theseus_gui/src/components/ui/tutorial/OnboardingModal.vue create mode 100644 theseus_gui/src/helpers/mr_auth.js diff --git a/Cargo.lock b/Cargo.lock index 1c0adccd7..c40413bbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4609,7 +4609,7 @@ dependencies = [ [[package]] name = "theseus" -version = "0.3.1" +version = "0.4.0" dependencies = [ "async-recursion", "async-tungstenite", @@ -4654,7 +4654,7 @@ dependencies = [ [[package]] name = "theseus_cli" -version = "0.3.1" +version = "0.4.0" dependencies = [ "argh", "color-eyre", @@ -4681,7 +4681,7 @@ dependencies = [ [[package]] name = "theseus_gui" -version = "0.3.1" +version = "0.4.0" dependencies = [ "chrono", "cocoa", diff --git a/theseus/Cargo.toml b/theseus/Cargo.toml index 429db24b7..d6f0300c2 100644 --- a/theseus/Cargo.toml +++ b/theseus/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "theseus" -version = "0.3.1" +version = "0.4.0" authors = ["Jai A "] edition = "2018" diff --git a/theseus/src/api/auth.rs b/theseus/src/api/auth.rs index 4db321239..91807b7c4 100644 --- a/theseus/src/api/auth.rs +++ b/theseus/src/api/auth.rs @@ -20,7 +20,8 @@ pub async fn authenticate_begin_flow() -> crate::Result { /// 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 { +pub async fn authenticate_await_complete_flow( +) -> crate::Result<(Credentials, Option)> { let credentials = AuthTask::await_auth_completion().await?; Ok(credentials) } @@ -38,7 +39,7 @@ pub async fn cancel_flow() -> crate::Result<()> { #[theseus_macros::debug_pin] pub async fn authenticate( browser_url: oneshot::Sender, -) -> crate::Result { +) -> crate::Result<(Credentials, Option)> { let mut flow = inner::HydraAuthFlow::new().await?; let state = State::get().await?; @@ -52,12 +53,12 @@ pub async fn authenticate( let credentials = flow.extract_credentials(&state.fetch_semaphore).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() { let mut settings = state.settings.write().await; - settings.default_user = Some(credentials.id); + settings.default_user = Some(credentials.0.id); } Ok(credentials) @@ -79,8 +80,17 @@ pub async fn refresh(user: uuid::Uuid) -> crate::Result { })?; let fetch_semaphore = &state.fetch_semaphore; - if Utc::now() > credentials.expires { - inner::refresh_credentials(&mut credentials, fetch_semaphore).await?; + if Utc::now() > credentials.expires + && 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?; diff --git a/theseus/src/api/jre.rs b/theseus/src/api/jre.rs index 3624859d5..eae027eb6 100644 --- a/theseus/src/api/jre.rs +++ b/theseus/src/api/jre.rs @@ -4,6 +4,7 @@ use serde::Deserialize; 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; @@ -97,6 +98,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result { None, None, &state.fetch_semaphore, + &CredentialsStore(None), ).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 { None, Some((&loading_bar, 80.0)), &state.fetch_semaphore, + &CredentialsStore(None), ) .await?; diff --git a/theseus/src/api/mod.rs b/theseus/src/api/mod.rs index 5260635cd..9c14efa9d 100644 --- a/theseus/src/api/mod.rs +++ b/theseus/src/api/mod.rs @@ -4,6 +4,7 @@ pub mod handler; pub mod jre; pub mod logs; pub mod metadata; +pub mod mr_auth; pub mod pack; pub mod process; pub mod profile; @@ -14,9 +15,9 @@ pub mod tags; pub mod data { pub use crate::state::{ DirectoryInfo, Hooks, JavaSettings, LinkedData, MemorySettings, - ModLoader, ModrinthProject, ModrinthTeamMember, ModrinthUser, - ModrinthVersion, ProfileMetadata, ProjectMetadata, Settings, Theme, - WindowSize, + ModLoader, ModrinthCredentials, ModrinthCredentialsResult, + ModrinthProject, ModrinthTeamMember, ModrinthUser, ModrinthVersion, + ProfileMetadata, ProjectMetadata, Settings, Theme, WindowSize, }; } diff --git a/theseus/src/api/mr_auth.rs b/theseus/src/api/mr_auth.rs new file mode 100644 index 000000000..78d57fc2f --- /dev/null +++ b/theseus/src/api/mr_auth.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + let state = crate::State::get().await?; + let read = state.credentials.read().await; + + Ok(read.0.clone()) +} diff --git a/theseus/src/api/pack/import/curseforge.rs b/theseus/src/api/pack/import/curseforge.rs index b1d9c2e64..b13d2961f 100644 --- a/theseus/src/api/pack/import/curseforge.rs +++ b/theseus/src/api/pack/import/curseforge.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use crate::state::CredentialsStore; use crate::{ prelude::{ModLoader, ProfilePathId}, state::ProfileInstallStage, @@ -90,8 +91,13 @@ pub async fn import_curseforge( thumbnail_url: Some(thumbnail_url), }) = minecraft_instance.installed_modpack.clone() { - let icon_bytes = - fetch(&thumbnail_url, None, &state.fetch_semaphore).await?; + let icon_bytes = fetch( + &thumbnail_url, + None, + &state.fetch_semaphore, + &CredentialsStore(None), + ) + .await?; let filename = thumbnail_url.rsplit('/').last(); if let Some(filename) = filename { icon = Some( diff --git a/theseus/src/api/pack/install_from.rs b/theseus/src/api/pack/install_from.rs index e9723f281..0b9266179 100644 --- a/theseus/src/api/pack/install_from.rs +++ b/theseus/src/api/pack/install_from.rs @@ -192,12 +192,14 @@ pub async fn generate_pack_from_version_id( .await?; emit_loading(&loading_bar, 0.0, Some("Fetching version")).await?; + let creds = state.credentials.read().await; let version: ModrinthVersion = fetch_json( Method::GET, &format!("{}version/{}", MODRINTH_API_URL, version_id), None, None, &state.fetch_semaphore, + &creds, ) .await?; emit_loading(&loading_bar, 10.0, None).await?; @@ -225,6 +227,7 @@ pub async fn generate_pack_from_version_id( None, Some((&loading_bar, 70.0)), &state.fetch_semaphore, + &creds, ) .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, &state.fetch_semaphore, + &creds, ) .await?; emit_loading(&loading_bar, 10.0, Some("Retrieving icon")).await?; let icon = if let Some(icon_url) = project.icon_url { 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(); diff --git a/theseus/src/api/pack/install_mrpack.rs b/theseus/src/api/pack/install_mrpack.rs index d2c9aaec3..1723d5462 100644 --- a/theseus/src/api/pack/install_mrpack.rs +++ b/theseus/src/api/pack/install_mrpack.rs @@ -168,6 +168,7 @@ pub async fn install_zipped_mrpack_files( } } + let creds = state.credentials.read().await; let file = fetch_mirrors( &project .downloads @@ -176,8 +177,10 @@ pub async fn install_zipped_mrpack_files( .collect::>(), project.hashes.get(&PackFileHash::Sha1).map(|x| &**x), &state.fetch_semaphore, + &creds, ) .await?; + drop(creds); let path = std::path::Path::new(&project.path).components().next(); diff --git a/theseus/src/launcher/auth.rs b/theseus/src/launcher/auth.rs index b91889289..99f8c7678 100644 --- a/theseus/src/launcher/auth.rs +++ b/theseus/src/launcher/auth.rs @@ -1,4 +1,6 @@ //! 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 chrono::{prelude::*, Duration}; @@ -10,7 +12,7 @@ use url::Url; lazy_static! { 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"); } @@ -40,7 +42,7 @@ struct TokenJSON { token: String, refresh_token: String, expires_after: u32, - flow: String, + flow: Option, } #[derive(Deserialize)] @@ -68,7 +70,7 @@ pub struct HydraAuthFlow { impl HydraAuthFlow { pub async fn new() -> crate::Result { let (socket, _) = ws::tokio::connect_async( - "wss://staging-api.modrinth.com/v2/auth/minecraft/ws", + "wss://api.modrinth.com/v2/auth/minecraft/ws", ) .await?; Ok(Self { socket }) @@ -96,7 +98,7 @@ impl HydraAuthFlow { pub async fn extract_credentials( &mut self, semaphore: &FetchSemaphore, - ) -> crate::Result { + ) -> crate::Result<(Credentials, Option)> { // Minecraft bearer token let token_resp = self .socket @@ -117,14 +119,17 @@ impl HydraAuthFlow { 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, - }) + 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, + )) } } @@ -134,10 +139,11 @@ pub async fn refresh_credentials( ) -> crate::Result<()> { let resp = fetch_json::( Method::POST, - "https://staging-api.modrinth.com/v2/auth/minecraft/refresh", + &format!("{MODRINTH_API_URL}auth/minecraft/refresh"), None, Some(serde_json::json!({ "refresh_token": credentials.refresh_token })), semaphore, + &CredentialsStore(None), ) .await?; @@ -162,6 +168,7 @@ async fn fetch_info( Some(("Authorization", &format!("Bearer {token}"))), None, semaphore, + &CredentialsStore(None), ) .await?; let value = serde_json::from_slice(&result)?; diff --git a/theseus/src/launcher/download.rs b/theseus/src/launcher/download.rs index 78481de2f..9d24988da 100644 --- a/theseus/src/launcher/download.rs +++ b/theseus/src/launcher/download.rs @@ -1,5 +1,6 @@ //! Downloader for Minecraft data +use crate::state::CredentialsStore; use crate::{ event::{ emit::{emit_loading, loading_try_for_each_concurrent}, @@ -127,6 +128,7 @@ pub async fn download_client( &client_download.url, Some(&client_download.sha1), &st.fetch_semaphore, + &CredentialsStore(None), ) .await?; write(&path, &bytes, &st.io_semaphore).await?; @@ -206,7 +208,7 @@ pub async fn download_assets( async { if !resource_path.exists() { 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?; write(&resource_path, resource, &st.io_semaphore).await?; tracing::trace!("Fetched asset with hash {hash}"); @@ -216,7 +218,7 @@ pub async fn download_assets( async { if with_legacy { 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?; let resource_path = st.directories.legacy_assets_dir().await.join( name.replace('/', &String::from(std::path::MAIN_SEPARATOR)) @@ -273,7 +275,7 @@ pub async fn download_libraries( 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?; write(&path, &bytes, &st.io_semaphore).await?; tracing::trace!("Fetched library {} to path {:?}", &library.name, &path); @@ -288,7 +290,7 @@ pub async fn download_libraries( &artifact_path ].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?; tracing::trace!("Fetched library {} to path {:?}", &library.name, &path); Ok::<_, crate::Error>(()) @@ -314,7 +316,7 @@ pub async fn download_libraries( ); 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); if let Ok(mut archive) = zip::ZipArchive::new(reader) { match archive.extract(st.directories.version_natives_dir(version).await) { diff --git a/theseus/src/state/auth_task.rs b/theseus/src/state/auth_task.rs index a87509fb4..f73828b9d 100644 --- a/theseus/src/state/auth_task.rs +++ b/theseus/src/state/auth_task.rs @@ -6,7 +6,10 @@ use tokio::task::JoinHandle; // A wrapper over the authentication task that allows it to be called from the frontend // without caching the task handle in the frontend -pub struct AuthTask(Option>>); +pub struct AuthTask( + #[allow(clippy::type_complexity)] + Option)>>>, +); impl AuthTask { pub fn new() -> AuthTask { @@ -37,7 +40,8 @@ impl AuthTask { Ok(url) } - pub async fn await_auth_completion() -> crate::Result { + pub async fn await_auth_completion( + ) -> crate::Result<(Credentials, Option)> { // Gets the task handle from the state, replacing with None let task = { let state = crate::State::get().await?; diff --git a/theseus/src/state/mod.rs b/theseus/src/state/mod.rs index 96992750d..24ace5c18 100644 --- a/theseus/src/state/mod.rs +++ b/theseus/src/state/mod.rs @@ -52,6 +52,9 @@ pub use self::safe_processes::*; mod discord; pub use self::discord::*; +mod mr_auth; +pub use self::mr_auth::*; + // Global state // RwLock on state only has concurrent reads, except for config dir change which takes control of the State static LAUNCHER_STATE: OnceCell> = OnceCell::const_new(); @@ -77,16 +80,20 @@ pub struct State { pub settings: RwLock, /// Reference to minecraft process children pub children: RwLock, - /// Authentication flow - pub auth_flow: RwLock, /// Launcher profile metadata pub(crate) profiles: RwLock, - /// Launcher user account info - pub(crate) users: RwLock, /// Launcher tags pub(crate) tags: RwLock, /// Launcher processes that should be safely exited on shutdown pub(crate) safety_processes: RwLock, + /// Launcher user account info + pub(crate) users: RwLock, + /// Authentication flow + pub auth_flow: RwLock, + /// Modrinth Credentials Store + pub credentials: RwLock, + /// Modrinth auth flow + pub modrinth_auth_flow: RwLock>, /// Discord RPC pub discord_rpc: DiscordGuard, @@ -159,15 +166,18 @@ impl State { !is_offline, &io_semaphore, &fetch_semaphore, + &CredentialsStore(None), ); let users_fut = Users::init(&directories, &io_semaphore); + let creds_fut = CredentialsStore::init(&directories, &io_semaphore); // 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"); metadata_fut, profiles_fut, tags_fut, users_fut, + creds_fut, }?; let children = Children::new(); @@ -198,10 +208,12 @@ impl State { users: RwLock::new(users), children: RwLock::new(children), auth_flow: RwLock::new(auth_flow), + credentials: RwLock::new(creds), tags: RwLock::new(tags), discord_rpc, safety_processes: RwLock::new(safety_processes), 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 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 { if let Ok(state) = crate::State::get().await { if !*state.offline.read().await { @@ -230,8 +247,9 @@ impl State { let res3 = Metadata::update(); let res4 = Profiles::update_projects(); 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); } } }); diff --git a/theseus/src/state/mr_auth.rs b/theseus/src/state/mr_auth.rs new file mode 100644 index 000000000..54ae3f617 --- /dev/null +++ b/theseus/src/state/mr_auth.rs @@ -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, + pub avatar_url: Option, + pub bio: Option, + pub created: DateTime, + pub role: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ModrinthCredentials { + pub session: String, + pub expires_at: DateTime, + 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); + +impl CredentialsStore { + pub async fn init( + dirs: &DirectoryInfo, + io_semaphore: &IoSemaphore, + ) -> crate::Result { + 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 { + 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 { + 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::(&code_resp)?; + + Ok(response.url) + } + + pub async fn extract_credentials( + &mut self, + semaphore: &FetchSemaphore, + ) -> crate::Result { + // 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::>(&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, + semaphore: &FetchSemaphore, +) -> crate::Result { + 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 { + 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::>(&resp)?; + + get_result_from_res("session", value, semaphore).await +} + +async fn get_creds_from_res( + response: HashMap, + semaphore: &FetchSemaphore, +) -> crate::Result { + 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 { + 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::>(&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 { + 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::>(&resp)?; + + get_creds_from_res(response, semaphore).await +} + +pub async fn login_minecraft( + flow: &str, + semaphore: &FetchSemaphore, +) -> crate::Result { + 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::>(&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::(&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 { + 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) +} diff --git a/theseus/src/state/profiles.rs b/theseus/src/state/profiles.rs index 83ab831dd..bb278c0c8 100644 --- a/theseus/src/state/profiles.rs +++ b/theseus/src/state/profiles.rs @@ -342,14 +342,17 @@ impl Profile { 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 mut new_profiles = state.profiles.write().await; if let Some(profile) = new_profiles.0.get_mut(&profile_path_id) { @@ -462,14 +465,17 @@ impl Profile { version_id: String, ) -> crate::Result<(ProjectPathId, ModrinthVersion)> { let state = State::get().await?; + let creds = state.credentials.read().await; let version = fetch_json::( Method::GET, &format!("{MODRINTH_API_URL}version/{version_id}"), None, None, &state.fetch_semaphore, + &creds, ) .await?; + drop(creds); let file = if let Some(file) = version.files.iter().find(|x| x.primary) { file @@ -482,12 +488,15 @@ impl Profile { .into()); }; + let creds = state.credentials.read().await; let bytes = fetch( &file.url, file.hashes.get("sha1").map(|x| &**x), &state.fetch_semaphore, + &creds, ) .await?; + drop(creds); let path = self .add_project_bytes( &file.filename, @@ -736,14 +745,17 @@ impl Profiles { future::try_join_all(files.into_iter().map( |(profile, files)| async { let profile_name = profile.profile_id(); + let creds = state.credentials.read().await; let inferred = super::projects::infer_data_from_files( profile, files, caches_dir.clone(), &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_name) @@ -803,6 +815,7 @@ impl Profiles { let linked_project = linked_project; let state = state.clone(); async move { + let creds = state.credentials.read().await; let versions: Vec = fetch_json( Method::GET, &format!( @@ -813,8 +826,10 @@ impl Profiles { None, None, &state.fetch_semaphore, + &creds, ) .await?; + 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 diff --git a/theseus/src/state/projects.rs b/theseus/src/state/projects.rs index e5cf6fe9c..02e8fe2b6 100644 --- a/theseus/src/state/projects.rs +++ b/theseus/src/state/projects.rs @@ -1,7 +1,7 @@ //! Project management + inference use crate::config::MODRINTH_API_URL; -use crate::state::Profile; +use crate::state::{CredentialsStore, ModrinthUser, Profile}; use crate::util::fetch::{ fetch_json, write_cached_icon, FetchSemaphore, IoSemaphore, }; @@ -168,18 +168,6 @@ pub struct ModrinthTeamMember { pub ordering: i64, } -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct ModrinthUser { - pub id: String, - pub github_id: Option, - pub username: String, - pub name: Option, - pub avatar_url: Option, - pub bio: Option, - pub created: DateTime, - pub role: String, -} - #[derive(Serialize, Deserialize, Copy, Clone, Debug)] #[serde(rename_all = "lowercase")] pub enum DependencyType { @@ -289,6 +277,7 @@ pub async fn infer_data_from_files( cache_dir: PathBuf, io_semaphore: &IoSemaphore, fetch_semaphore: &FetchSemaphore, + credentials: &CredentialsStore, ) -> crate::Result> { let mut file_path_hashes = HashMap::new(); @@ -327,6 +316,7 @@ pub async fn infer_data_from_files( "algorithm": "sha512", })), fetch_semaphore, + credentials, ), fetch_json::>( Method::POST, @@ -339,6 +329,7 @@ pub async fn infer_data_from_files( "game_versions": [profile.metadata.game_version] })), fetch_semaphore, + credentials, ) )?; @@ -357,6 +348,7 @@ pub async fn infer_data_from_files( None, None, fetch_semaphore, + credentials, ) .await?; @@ -374,6 +366,7 @@ pub async fn infer_data_from_files( None, None, fetch_semaphore, + credentials, ) .await? .into_iter() diff --git a/theseus/src/state/settings.rs b/theseus/src/state/settings.rs index ab55b3aac..526e60e7b 100644 --- a/theseus/src/state/settings.rs +++ b/theseus/src/state/settings.rs @@ -41,7 +41,7 @@ pub struct Settings { #[serde(default)] pub advanced_rendering: bool, #[serde(default)] - pub onboarded_new: bool, + pub fully_onboarded: bool, #[serde(default = "DirectoryInfo::get_initial_settings_dir")] pub loaded_config_dir: Option, } @@ -82,7 +82,7 @@ impl Settings { developer_mode: false, opt_out_analytics: false, advanced_rendering: true, - onboarded_new: false, + fully_onboarded: false, // By default, the config directory is the same as the settings directory loaded_config_dir: DirectoryInfo::get_initial_settings_dir(), diff --git a/theseus/src/state/tags.rs b/theseus/src/state/tags.rs index 88e6ce53b..d28d935d2 100644 --- a/theseus/src/state/tags.rs +++ b/theseus/src/state/tags.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::config::MODRINTH_API_URL; use crate::data::DirectoryInfo; +use crate::state::CredentialsStore; use crate::util::fetch::{ fetch_json, read_json, write, FetchSemaphore, IoSemaphore, }; @@ -27,6 +28,7 @@ impl Tags { fetch_online: bool, io_semaphore: &IoSemaphore, fetch_semaphore: &FetchSemaphore, + credentials: &CredentialsStore, ) -> crate::Result { let mut tags = None; let tags_path = dirs.caches_meta_dir().await.join("tags.json"); @@ -35,7 +37,7 @@ impl Tags { { tags = Some(tags_json); } else if fetch_online { - match Self::fetch(fetch_semaphore).await { + match Self::fetch(fetch_semaphore, credentials).await { Ok(tags_fetch) => tags = Some(tags_fetch), Err(err) => { tracing::warn!("Unable to fetch launcher tags: {err}") @@ -58,7 +60,11 @@ impl Tags { pub async fn update() { let res = async { 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 = 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 - pub async fn fetch(semaphore: &FetchSemaphore) -> crate::Result { + pub async fn fetch( + semaphore: &FetchSemaphore, + credentials: &CredentialsStore, + ) -> crate::Result { let categories = format!("{MODRINTH_API_URL}tag/category"); let loaders = format!("{MODRINTH_API_URL}tag/loader"); let game_versions = format!("{MODRINTH_API_URL}tag/game_version"); @@ -137,6 +146,7 @@ impl Tags { None, None, semaphore, + credentials, ); let loaders_fut = fetch_json::>( Method::GET, @@ -144,6 +154,7 @@ impl Tags { None, None, semaphore, + credentials, ); let game_versions_fut = fetch_json::>( Method::GET, @@ -151,6 +162,7 @@ impl Tags { None, None, semaphore, + credentials, ); let donation_platforms_fut = fetch_json::>( Method::GET, @@ -158,6 +170,7 @@ impl Tags { None, None, semaphore, + credentials, ); let report_types_fut = fetch_json::>( Method::GET, @@ -165,6 +178,7 @@ impl Tags { None, None, semaphore, + credentials, ); let ( diff --git a/theseus/src/util/fetch.rs b/theseus/src/util/fetch.rs index 510ba7faf..72ebe557e 100644 --- a/theseus/src/util/fetch.rs +++ b/theseus/src/util/fetch.rs @@ -1,6 +1,7 @@ //! Functions for fetching infromation from the Internet use crate::event::emit::emit_loading; use crate::event::LoadingBarId; +use crate::state::CredentialsStore; use bytes::Bytes; use lazy_static::lazy_static; use reqwest::Method; @@ -41,8 +42,19 @@ pub async fn fetch( url: &str, sha1: Option<&str>, semaphore: &FetchSemaphore, + credentials: &CredentialsStore, ) -> crate::Result { - 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))] @@ -52,13 +64,22 @@ pub async fn fetch_json( sha1: Option<&str>, json_body: Option, semaphore: &FetchSemaphore, + credentials: &CredentialsStore, ) -> crate::Result where T: DeserializeOwned, { - let result = - fetch_advanced(method, url, sha1, json_body, None, None, semaphore) - .await?; + let result = fetch_advanced( + method, + url, + sha1, + json_body, + None, + None, + semaphore, + credentials, + ) + .await?; let value = serde_json::from_slice(&result)?; Ok(value) } @@ -66,6 +87,7 @@ where /// Downloads a file with retry and checksum functionality #[tracing::instrument(skip(json_body, semaphore))] #[theseus_macros::debug_pin] +#[allow(clippy::too_many_arguments)] pub async fn fetch_advanced( method: Method, url: &str, @@ -74,6 +96,7 @@ pub async fn fetch_advanced( header: Option<(&str, &str)>, loading_bar: Option<(&LoadingBarId, f64)>, semaphore: &FetchSemaphore, + credentials: &CredentialsStore, ) -> crate::Result { let io_semaphore = semaphore.0.read().await; let _permit = io_semaphore.acquire().await?; @@ -89,6 +112,12 @@ pub async fn fetch_advanced( 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; match result { Ok(x) => { @@ -163,6 +192,7 @@ pub async fn fetch_mirrors( mirrors: &[&str], sha1: Option<&str>, semaphore: &FetchSemaphore, + credentials: &CredentialsStore, ) -> crate::Result { if mirrors.is_empty() { return Err(crate::ErrorKind::InputError( @@ -172,7 +202,7 @@ pub async fn fetch_mirrors( } 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)) { return result; @@ -186,7 +216,12 @@ pub async fn fetch_mirrors( #[tracing::instrument(skip(semaphore))] #[theseus_macros::debug_pin] 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 = tokio::time::timeout(Duration::from_secs(timeout), result).await; matches!(result, Ok(Ok(_))) diff --git a/theseus_cli/Cargo.toml b/theseus_cli/Cargo.toml index 622e84012..73aa8915d 100644 --- a/theseus_cli/Cargo.toml +++ b/theseus_cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "theseus_cli" -version = "0.3.1" +version = "0.4.0" authors = ["Jai A "] edition = "2018" diff --git a/theseus_cli/src/subcommands/user.rs b/theseus_cli/src/subcommands/user.rs index a6e7ec641..01603a237 100644 --- a/theseus_cli/src/subcommands/user.rs +++ b/theseus_cli/src/subcommands/user.rs @@ -52,7 +52,7 @@ impl UserAdd { let credentials = flow.await??; State::sync().await?; - success!("Logged in user {}.", credentials.username); + success!("Logged in user {}.", credentials.0.username); Ok(()) } } diff --git a/theseus_gui/package.json b/theseus_gui/package.json index ee4e960da..0dbba1653 100644 --- a/theseus_gui/package.json +++ b/theseus_gui/package.json @@ -1,7 +1,7 @@ { "name": "theseus_gui", "private": true, - "version": "0.3.1", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/theseus_gui/pnpm-lock.yaml b/theseus_gui/pnpm-lock.yaml index c20577138..80983ca43 100644 --- a/theseus_gui/pnpm-lock.yaml +++ b/theseus_gui/pnpm-lock.yaml @@ -1,4 +1,8 @@ -lockfileVersion: '6.0' +lockfileVersion: '6.1' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false dependencies: '@tauri-apps/api': diff --git a/theseus_gui/src-tauri/Cargo.toml b/theseus_gui/src-tauri/Cargo.toml index 2fc2b24ec..c95589920 100644 --- a/theseus_gui/src-tauri/Cargo.toml +++ b/theseus_gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "theseus_gui" -version = "0.3.1" +version = "0.4.0" description = "A Tauri App" authors = ["you"] license = "" diff --git a/theseus_gui/src-tauri/src/api/auth.rs b/theseus_gui/src-tauri/src/api/auth.rs index 0b6ceef4b..3a7fded33 100644 --- a/theseus_gui/src-tauri/src/api/auth.rs +++ b/theseus_gui/src-tauri/src/api/auth.rs @@ -28,7 +28,8 @@ pub async fn auth_authenticate_begin_flow() -> Result { /// 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 { +pub async fn auth_authenticate_await_completion( +) -> Result<(Credentials, Option)> { Ok(auth::authenticate_await_complete_flow().await?) } diff --git a/theseus_gui/src-tauri/src/api/mod.rs b/theseus_gui/src-tauri/src/api/mod.rs index 2bfb0c966..d2a6b0757 100644 --- a/theseus_gui/src-tauri/src/api/mod.rs +++ b/theseus_gui/src-tauri/src/api/mod.rs @@ -7,6 +7,7 @@ pub mod import; pub mod jre; pub mod logs; pub mod metadata; +pub mod mr_auth; pub mod pack; pub mod process; pub mod profile; diff --git a/theseus_gui/src-tauri/src/api/mr_auth.rs b/theseus_gui/src-tauri/src/api/mr_auth.rs new file mode 100644 index 000000000..0f8f1534e --- /dev/null +++ b/theseus_gui/src-tauri/src/api/mr_auth.rs @@ -0,0 +1,88 @@ +use crate::api::Result; +use tauri::plugin::TauriPlugin; +use theseus::prelude::*; + +pub fn init() -> TauriPlugin { + 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 { + Ok(theseus::mr_auth::authenticate_begin_flow(provider).await?) +} + +#[tauri::command] +pub async fn authenticate_await_completion() -> Result +{ + 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 { + Ok(theseus::mr_auth::login_password(username, password, challenge).await?) +} + +#[tauri::command] +pub async fn login_2fa(code: &str, flow: &str) -> Result { + Ok(theseus::mr_auth::login_2fa(code, flow).await?) +} + +#[tauri::command] +pub async fn login_minecraft(flow: &str) -> Result { + 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 { + 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> { + Ok(theseus::mr_auth::get_credentials().await?) +} diff --git a/theseus_gui/src-tauri/src/main.rs b/theseus_gui/src-tauri/src/main.rs index e8507064a..6d7cba8d4 100644 --- a/theseus_gui/src-tauri/src/main.rs +++ b/theseus_gui/src-tauri/src/main.rs @@ -127,6 +127,7 @@ fn main() { } let builder = builder .plugin(api::auth::init()) + .plugin(api::mr_auth::init()) .plugin(api::import::init()) .plugin(api::logs::init()) .plugin(api::jre::init()) diff --git a/theseus_gui/src-tauri/tauri.conf.json b/theseus_gui/src-tauri/tauri.conf.json index 06f5bc6ef..988242373 100644 --- a/theseus_gui/src-tauri/tauri.conf.json +++ b/theseus_gui/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "Modrinth App", - "version": "0.3.1" + "version": "0.4.0" }, "tauri": { "allowlist": { diff --git a/theseus_gui/src/App.vue b/theseus_gui/src/App.vue index a37541d1e..4743be295 100644 --- a/theseus_gui/src/App.vue +++ b/theseus_gui/src/App.vue @@ -54,14 +54,14 @@ const onboardingVideo = ref() defineExpose({ initialize: async () => { 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() const os = await getOS() // 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 version = await getVersion() - showOnboarding.value = !onboarded_new + showOnboarding.value = !fully_onboarded themeStore.setThemeState(theme) themeStore.collapsedNavigation = collapsed_navigation @@ -71,7 +71,7 @@ defineExpose({ if (opt_out_analytics) { 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()) diff --git a/theseus_gui/src/components/ui/AccountsCard.vue b/theseus_gui/src/components/ui/AccountsCard.vue index 710e8618e..0401554d6 100644 --- a/theseus_gui/src/components/ui/AccountsCard.vue +++ b/theseus_gui/src/components/ui/AccountsCard.vue @@ -78,6 +78,7 @@ import { 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' defineProps({ @@ -124,8 +125,20 @@ async function login() { }) 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() mixpanel_track('AccountLogIn') } diff --git a/theseus_gui/src/components/ui/tutorial/LoginCard.vue b/theseus_gui/src/components/ui/tutorial/LoginCard.vue index 61e4b268a..d2a53493e 100644 --- a/theseus_gui/src/components/ui/tutorial/LoginCard.vue +++ b/theseus_gui/src/components/ui/tutorial/LoginCard.vue @@ -35,9 +35,10 @@ async function login() { const loggedIn = await authenticate_await_completion().catch(handleError) loginModal.value.hide() - props.nextPage() + + props.nextPage(loggedIn[1]) const settings = await get().catch(handleError) - settings.default_user = loggedIn.id + settings.default_user = loggedIn[0].id await set(settings).catch(handleError) await mixpanel.track('AccountLogIn') } @@ -83,7 +84,7 @@ const openUrl = async () => { Browser didn't open? - + diff --git a/theseus_gui/src/components/ui/tutorial/ModrinthLoginScreen.vue b/theseus_gui/src/components/ui/tutorial/ModrinthLoginScreen.vue index 177bd4b24..7bf711d61 100644 --- a/theseus_gui/src/components/ui/tutorial/ModrinthLoginScreen.vue +++ b/theseus_gui/src/components/ui/tutorial/ModrinthLoginScreen.vue @@ -1,5 +1,5 @@ @@ -179,4 +333,10 @@ defineProps({ padding: var(--gap-md) 0; } } + +:deep { + .checkbox { + border: none; + } +} diff --git a/theseus_gui/src/components/ui/tutorial/OnboardingModal.vue b/theseus_gui/src/components/ui/tutorial/OnboardingModal.vue deleted file mode 100644 index 144096ba3..000000000 --- a/theseus_gui/src/components/ui/tutorial/OnboardingModal.vue +++ /dev/null @@ -1,338 +0,0 @@ - - - - - diff --git a/theseus_gui/src/components/ui/tutorial/OnboardingScreen.vue b/theseus_gui/src/components/ui/tutorial/OnboardingScreen.vue index 1d2e22e0b..b4cea83da 100644 --- a/theseus_gui/src/components/ui/tutorial/OnboardingScreen.vue +++ b/theseus_gui/src/components/ui/tutorial/OnboardingScreen.vue @@ -32,7 +32,7 @@ import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue' import { auto_install_java, get_jre } from '@/helpers/jre.js' import { handleError } from '@/store/notifications.js' 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' const phase = ref(0) @@ -45,6 +45,8 @@ const props = defineProps({ }, }) +const flow = ref('') + const nextPhase = () => { phase.value++ mixpanel.track('TutorialPhase', { page: phase.value }) @@ -54,9 +56,13 @@ const prevPhase = () => { phase.value-- } -const nextPage = () => { +const nextPage = (newFlow) => { page.value++ mixpanel.track('OnboardingPage', { page: page.value }) + + if (newFlow) { + flow.value = newFlow + } } const endOnboarding = () => { @@ -70,7 +76,7 @@ const prevPage = () => { const finishOnboarding = async () => { mixpanel.track('OnboardingFinish') const settings = await get() - settings.onboarded_new = true + settings.fully_onboarded = true await set(settings) props.finish() } @@ -119,14 +125,20 @@ onMounted(async () => { - - + - +
diff --git a/theseus_gui/src/helpers/mr_auth.js b/theseus_gui/src/helpers/mr_auth.js new file mode 100644 index 000000000..2509e6785 --- /dev/null +++ b/theseus_gui/src/helpers/mr_auth.js @@ -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') +} diff --git a/theseus_gui/src/pages/Settings.vue b/theseus_gui/src/pages/Settings.vue index b13547c6f..06201f4dc 100644 --- a/theseus_gui/src/pages/Settings.vue +++ b/theseus_gui/src/pages/Settings.vue @@ -1,10 +1,12 @@