diff --git a/apps/oneclient/desktop/src/constants.rs b/apps/oneclient/desktop/src/constants.rs index 71782903..39d17743 100644 --- a/apps/oneclient/desktop/src/constants.rs +++ b/apps/oneclient/desktop/src/constants.rs @@ -1,6 +1,13 @@ // === API === pub const CURSEFORGE_API_KEY: &str = "$2a$10$6utA1UNSmFPrE/Lh7b7ndeeGmiOkjKNY8kpFB0fsmE/d42ZAfFgCe"; pub const DISCORD_CLIENT_ID: &str = "1426999264633946334"; +pub const PLUS_BACKEND_URL: &str = match option_env!("ONECLIENT_PLUS_BACKEND_URL") { + Some(url) => url, + #[cfg(debug_assertions)] + None => "https://plus-staging.polyfrost.org", + #[cfg(not(debug_assertions))] + None => "https://plus.polyfrost.org", +}; // === Meta === // Cannot contain trailing slash diff --git a/apps/oneclient/desktop/src/lib.rs b/apps/oneclient/desktop/src/lib.rs index c773fabb..41ee34ff 100644 --- a/apps/oneclient/desktop/src/lib.rs +++ b/apps/oneclient/desktop/src/lib.rs @@ -34,6 +34,7 @@ async fn initialize_core() -> LauncherResult<()> { launcher_version: env!("CARGO_PKG_VERSION").to_string(), launcher_website: "https://polyfrost.org/".to_string(), discord_client_id: Some(constants::DISCORD_CLIENT_ID.to_string()), + plus_backend_url: Some(constants::PLUS_BACKEND_URL.to_string()), fetch_attempts: 3, logger_filter: Some(format!( "{}={level},onelauncher_core={level}", diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 25a08a82..61ceae4b 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -325,7 +325,7 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'oneclient':'{"getBundlesFor":["cluster_id"],"installUpdate":[],"cacheArt":["path"],"extractBundleOverrides":["bundle_path","cluster_id"],"getVersions":[],"updateBundlePackages":["cluster_id"],"isBundleSyncing":[],"checkForUpdate":[],"refreshArt":["path"],"copyClusterContent":["source_id","target_id"],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"getClustersGroupedByMajor":[]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'debug':'{"getFullDebugInfo":[],"getArch":[],"getFullDebugInfoParsed":[],"openDevTools":[],"getOsDistro":[],"getLocale":[],"getGitCommitHash":[],"isInDev":[],"getOsVersion":[],"getBuildTimestamp":[],"getFamily":[],"getFullDebugInfoParsedString":[],"hasDevSettings":[],"getPlatform":[],"getPackageVersion":[],"getType":[]}', 'core':'{"isClusterRunning":["cluster_id"],"readSettings":[],"launchCluster":["id","uuid","search_for_java"],"writeSettings":["setting"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getLinkedPackages":["cluster_id"],"getLogs":["id"],"setClusterStage":["id","stage"],"killProcess":["pid"],"updateClusterById":["id","request"],"refreshAccounts":[],"getProcessLogTail":["id","max_lines"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getGameVersions":[],"getWorlds":["id"],"removePackage":["cluster_id","package_hash"],"togglePackage":["cluster_id","package_hash"],"openMsaLogin":[],"getLogByName":["id","name"],"installModpack":["modpack","cluster_id"],"createCluster":["options"],"getProfileOrDefault":["name"],"getPackageBody":["provider","body"],"removeUser":["uuid"],"getUsersFromAuthor":["provider","author"],"fetchMinecraftProfile":["uuid"],"fetchLoggedInProfile":["access_token"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"getPackage":["provider","slug"],"searchPackages":["provider","query"],"setDefaultUser":["uuid"],"getLoadersForVersion":["mc_version"],"createSettingsProfile":["name"],"getMultiplePackages":["provider","slugs"],"syncCluster":["cluster_id"],"changeSkin":["access_token","skin_url","skin_variant"],"getUser":["uuid"],"getDefaultUser":["fallback"],"getRunningProcesses":[],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"removeCape":["access_token"],"setDiscordRPCMessage":["message"],"getRunningProcessesByClusterId":["cluster_id"],"open":["input"],"getClusterById":["id"],"updateClusterProfile":["name","profile"],"changeCape":["access_token","cape_uuid"],"convertUsernameUUID":["username_uuid"],"removeCluster":["id"],"getScreenshots":["id"],"getClusters":[],"getUsers":[],"getGlobalProfile":[],"refreshAccount":["uuid"]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}' } +const ARGS_MAP = { 'core':'{"searchPackages":["provider","query"],"refreshAccounts":[],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"syncCluster":["cluster_id"],"removePackage":["cluster_id","package_hash"],"installModpack":["modpack","cluster_id"],"fetchMinecraftProfile":["uuid"],"getProcessLogTail":["id","max_lines"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"changeCape":["access_token","cape_uuid"],"getPackage":["provider","slug"],"setDiscordRPCMessage":["message"],"changeSkin":["access_token","skin_url","skin_variant"],"removeCape":["access_token"],"readSettings":[],"getClusters":[],"launchCluster":["id","uuid","search_for_java"],"getLogs":["id"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getLoadersForVersion":["mc_version"],"getGameVersions":[],"removeCluster":["id"],"getClusterById":["id"],"convertUsernameUUID":["username_uuid"],"getLogByName":["id","name"],"createSettingsProfile":["name"],"updateClusterById":["id","request"],"getProfileOrDefault":["name"],"isClusterRunning":["cluster_id"],"openMsaLogin":[],"setDefaultUser":["uuid"],"writeSettings":["setting"],"getWorlds":["id"],"getUser":["uuid"],"fetchLoggedInProfile":["access_token"],"open":["input"],"getRunningProcessesByClusterId":["cluster_id"],"togglePackage":["cluster_id","package_hash"],"getUsersFromAuthor":["provider","author"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getUsers":[],"removeUser":["uuid"],"setClusterStage":["id","stage"],"createCluster":["options"],"killProcess":["pid"],"getLinkedPackages":["cluster_id"],"getDefaultUser":["fallback"],"getMultiplePackages":["provider","slugs"],"getRunningProcesses":[],"getScreenshots":["id"],"getGlobalProfile":[],"getPackageBody":["provider","body"],"updateClusterProfile":["name","profile"],"refreshAccount":["uuid"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'debug':'{"getType":[],"getOsDistro":[],"openDevTools":[],"getFamily":[],"getPackageVersion":[],"getGitCommitHash":[],"getFullDebugInfoParsed":[],"getOsVersion":[],"getArch":[],"getFullDebugInfoParsedString":[],"hasDevSettings":[],"getPlatform":[],"isInDev":[],"getFullDebugInfo":[],"getLocale":[],"getBuildTimestamp":[]}', 'oneclient':'{"refreshArt":["path"],"isBundleSyncing":[],"installUpdate":[],"updateBundlePackages":["cluster_id"],"getBundlesFor":["cluster_id"],"checkForUpdate":[],"getClustersGroupedByMajor":[],"extractBundleOverrides":["bundle_path","cluster_id"],"copyClusterContent":["source_id","target_id"],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"getVersions":[],"cacheArt":["path"]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}' } export type Router = { 'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, @@ -380,11 +380,6 @@ removeCape: (accessToken: string) => Promise, convertUsernameUUID: (usernameUuid: string) => Promise, setDiscordRPCMessage: (message: string) => Promise, open: (input: string) => Promise }, -'events': { ingress: (event: IngressPayload) => Promise, -message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise }, -'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise }, 'debug': { openDevTools: () => Promise, isInDev: () => Promise, getArch: () => Promise, @@ -412,7 +407,12 @@ updateBundlePackages: (clusterId: number) => Promise, isBundleSyncing: () => Promise, cacheArt: (path: string) => Promise, refreshArt: (path: string) => Promise, -copyClusterContent: (sourceId: number, targetId: number) => Promise } }; +copyClusterContent: (sourceId: number, targetId: number) => Promise }, +'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise }, +'events': { ingress: (event: IngressPayload) => Promise, +message: (event: MessagePayload) => Promise, +process: (event: ProcessPayload) => Promise } }; export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/components/overlay/AuthRefreshPrompt.tsx b/apps/oneclient/frontend/src/components/overlay/AuthRefreshPrompt.tsx new file mode 100644 index 00000000..22ccc05e --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/AuthRefreshPrompt.tsx @@ -0,0 +1,114 @@ +import { AccountAvatar, Overlay } from '@/components'; +import { bindings } from '@/main'; +import { useCommand, useCommandMut } from '@onelauncher/common'; +import { Button } from '@onelauncher/common/components'; +import { useQueryClient } from '@tanstack/react-query'; +import { + AlertTriangleIcon, + RefreshCw01Icon, +} from '@untitled-theme/icons-react'; +import { useMemo, useState } from 'react'; + +function getErrorMessage(error: unknown): string { + if (typeof error === 'string') + return error; + + if (error instanceof Error) + return error.message; + + if (typeof error === 'object' && error !== null) + return JSON.stringify(error); + + return String(error); +} + +function isExpiredRefreshError(error: unknown): boolean { + const message = getErrorMessage(error); + return ( + message.includes('invalid_grant') + || message.includes('grant is expired') + || message.includes('RefreshOAuthToken') + ); +} + +export function AuthRefreshPrompt() { + const queryClient = useQueryClient(); + const [dismissed, setDismissed] = useState(false); + + const refresh = useCommand( + ['refreshAccounts'], + bindings.core.refreshAccounts, + { + retry: false, + refetchOnWindowFocus: false, + }, + ); + const shouldPrompt = useMemo( + () => !dismissed && refresh.isError && isExpiredRefreshError(refresh.error), + [dismissed, refresh.error, refresh.isError], + ); + + const { + data: profile, + isPending, + mutate: signInAgain, + } = useCommandMut(bindings.core.openMsaLogin, { + onSuccess() { + setDismissed(true); + queryClient.invalidateQueries({ queryKey: ['getUsers'] }); + queryClient.invalidateQueries({ queryKey: ['getDefaultUser'] }); + queryClient.invalidateQueries({ queryKey: ['refreshAccounts'] }); + }, + }); + + if (!shouldPrompt) + return null; + + return ( + setDismissed(!open)} + > + +
+
+ +
+ Sign In Again +
+ +

+ Your Microsoft session expired. Sign in again to keep your account and + launch access up to date. +

+ + {profile && ( +
+ +
+

+ {profile.username} +

+

{profile.id}

+
+
+ )} + + +
+
+ ); +} diff --git a/apps/oneclient/frontend/src/components/overlay/index.ts b/apps/oneclient/frontend/src/components/overlay/index.ts index 9a82efa7..f0d882cc 100644 --- a/apps/oneclient/frontend/src/components/overlay/index.ts +++ b/apps/oneclient/frontend/src/components/overlay/index.ts @@ -1,5 +1,6 @@ export * from './AccountPopup'; export * from './AddAccountModal'; +export * from './AuthRefreshPrompt'; export * from './BundleModListModal'; export * from './DebugInfo'; export * from './ImportSkinModal'; diff --git a/apps/oneclient/frontend/src/routes/__root.tsx b/apps/oneclient/frontend/src/routes/__root.tsx index e8b8cefa..15c9fe6d 100644 --- a/apps/oneclient/frontend/src/routes/__root.tsx +++ b/apps/oneclient/frontend/src/routes/__root.tsx @@ -1,6 +1,6 @@ import type { QueryClient } from '@tanstack/react-query'; import type { NavigateOptions, ToOptions } from '@tanstack/react-router'; -import { Toasts } from '@/components'; +import { AuthRefreshPrompt, Toasts } from '@/components'; import { MigrationModal } from '@/components/overlay/MigrationModal'; import { useDebugKeybind } from '@/hooks/useDebugInfo'; import { useDiscordRPC } from '@/hooks/useDiscordRPC'; @@ -49,6 +49,7 @@ function RootRoute() { + {setting('seen_onboarding') && ( , + pub plus_backend_url: Option, pub logger_span_formatting: Option, pub logger_filter: Option, } @@ -37,6 +38,7 @@ impl Default for CoreOptions { msa_client_id: String::from("00000000402b5328"), msa_redirect_uri: String::from("https://login.live.com/oauth20_desktop.srf"), curseforge_api_key: None, + plus_backend_url: None, logger_span_formatting: None, logger_filter: None, } @@ -57,6 +59,7 @@ impl Default for CoreOptions { .ok() .unwrap_or_else(|| String::from("https://login.live.com/oauth20_desktop.srf")), curseforge_api_key: std::env::var("CURSEFORGE_API_KEY").ok(), + plus_backend_url: std::env::var("PLUS_BACKEND_URL").ok(), logger_span_formatting: None, logger_filter: None, } diff --git a/packages/core/src/store/credentials.rs b/packages/core/src/store/credentials.rs index 33be881b..22cee9c5 100644 --- a/packages/core/src/store/credentials.rs +++ b/packages/core/src/store/credentials.rs @@ -207,6 +207,14 @@ impl CredentialsStore { + chrono::TimeDelta::seconds(oauth_token.value.expires_in as i64), }; + if let Err(err) = record_plus_login(&credentials).await { + tracing::warn!( + "[auth] finish() - failed to record Poly+ login analytics for user {}: {:?}", + credentials.id, + err + ); + } + self.users.insert(profile_id, credentials.clone()); if self.default_user.is_none() { @@ -253,6 +261,14 @@ impl CredentialsStore { + chrono::TimeDelta::seconds(oauth_token.value.expires_in as i64), }; + if let Err(err) = record_plus_login(&val).await { + tracing::warn!( + "[auth] refresh_token() - failed to record Poly+ login analytics for user {}: {:?}", + val.id, + err + ); + } + self.users.insert(val.id, val.clone()); self.save().await?; @@ -505,7 +521,7 @@ struct OAuthToken { // pub foci: String, } -#[tracing::instrument] +#[tracing::instrument(skip(code, verify))] async fn oauth_token( code: &str, verify: &str, @@ -563,7 +579,7 @@ async fn oauth_token( }) } -#[tracing::instrument] +#[tracing::instrument(skip(refresh_token))] async fn oauth_refresh( refresh_token: &str, ) -> Result, MinecraftAuthError> { @@ -866,6 +882,76 @@ async fn minecraft_entitlements(token: &str) -> Result anyhow::Result<()> { + let Some(base_url) = Core::get().plus_backend_url.clone() else { + return Ok(()); + }; + + let server_id = generate_plus_server_id(); + let selected_profile = credentials.id.simple().to_string(); + + let join_res = auth_retry(|| { + crate::utils::http::REQWEST_CLIENT + .post("https://sessionserver.mojang.com/session/minecraft/join") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .json(&json!({ + "accessToken": credentials.access_token, + "selectedProfile": selected_profile, + "serverId": server_id, + })) + .send() + }) + .await?; + + if !join_res.status().is_success() { + let status = join_res.status(); + let body = join_res.text().await.unwrap_or_default(); + anyhow::bail!("Mojang join rejected Poly+ analytics login: status={status}, body={body}"); + } + + let mut login_url = + reqwest::Url::parse(&format!("{}/account/login", base_url.trim_end_matches('/')))?; + login_url + .query_pairs_mut() + .append_pair("username", &credentials.username) + .append_pair("server_id", &server_id); + + let plus_res = auth_retry(|| { + crate::utils::http::REQWEST_CLIENT + .post(login_url.clone()) + .header("Accept", "application/json") + .send() + }) + .await?; + + if !plus_res.status().is_success() { + let status = plus_res.status(); + let body = plus_res.text().await.unwrap_or_default(); + anyhow::bail!("Poly+ login rejected analytics login: status={status}, body={body}"); + } + + let _response = plus_res.json::().await?; + + Ok(()) +} + +fn generate_plus_server_id() -> String { + let mut rng = rand::thread_rng(); + let bytes: Vec = (0..20).map(|_| rng.r#gen::()).collect(); + bytes.iter().fold(String::new(), |mut output, b| { + let _ = write!(output, "{b:02x}"); + output + }) +} + #[tracing::instrument(skip(request_fn))] async fn auth_retry(request_fn: impl Fn() -> F) -> Result where