poly+ backend

This commit is contained in:
Julian Chang 2026-06-20 05:39:16 +07:00
parent a822f8d098
commit 1635ca38ce
No known key found for this signature in database
8 changed files with 223 additions and 10 deletions

View file

@ -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

View file

@ -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}",

View file

@ -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<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
@ -380,11 +380,6 @@ removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
setDiscordRPCMessage: (message: string) => Promise<null>,
open: (input: string) => Promise<null> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'debug': { openDevTools: () => Promise<void>,
isInDev: () => Promise<boolean>,
getArch: () => Promise<string>,
@ -412,7 +407,12 @@ updateBundlePackages: (clusterId: number) => Promise<ApplyBundleUpdatesResult>,
isBundleSyncing: () => Promise<boolean>,
cacheArt: (path: string) => Promise<string>,
refreshArt: (path: string) => Promise<null>,
copyClusterContent: (sourceId: number, targetId: number) => Promise<CopyClusterResult> } };
copyClusterContent: (sourceId: number, targetId: number) => Promise<CopyClusterResult> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> } };
export type { InferCommandOutput }

View file

@ -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 (
<Overlay
isDismissable
isOpen={shouldPrompt}
onOpenChange={open => setDismissed(!open)}
>
<Overlay.Dialog isDismissable>
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-warning/20">
<AlertTriangleIcon className="w-4 h-4 text-code-warn" />
</div>
<Overlay.Title>Sign In Again</Overlay.Title>
</div>
<p className="text-fg-secondary text-sm text-center max-w-sm">
Your Microsoft session expired. Sign in again to keep your account and
launch access up to date.
</p>
{profile && (
<div className="flex flex-row items-center justify-start gap-2">
<AccountAvatar
className="aspect-square h-12 rounded-sm"
uuid={profile.id}
/>
<div className="text-left flex flex-col">
<p className="flex items-center gap-1 text-fg-primary font-semibold">
{profile.username}
</p>
<p className="text-fg-secondary text-sm">{profile.id}</p>
</div>
</div>
)}
<Button
className="w-full"
color="primary"
isPending={isPending}
onPress={() => signInAgain()}
size="large"
>
<RefreshCw01Icon className="w-4 h-4" />
Sign in again
</Button>
</Overlay.Dialog>
</Overlay>
);
}

View file

@ -1,5 +1,6 @@
export * from './AccountPopup';
export * from './AddAccountModal';
export * from './AuthRefreshPrompt';
export * from './BundleModListModal';
export * from './DebugInfo';
export * from './ImportSkinModal';

View file

@ -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() {
</div>
<Toasts />
<AuthRefreshPrompt />
{setting('seen_onboarding') && (
<MigrationModal

View file

@ -21,6 +21,7 @@ pub struct CoreOptions {
pub msa_client_id: String,
pub msa_redirect_uri: String,
pub curseforge_api_key: Option<String>,
pub plus_backend_url: Option<String>,
pub logger_span_formatting: Option<FmtSpan>,
pub logger_filter: Option<String>,
}
@ -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,
}

View file

@ -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<RequestWithDate<OAuthToken>, MinecraftAuthError> {
@ -866,6 +882,76 @@ async fn minecraft_entitlements(token: &str) -> Result<MinecraftEntitlements, Mi
})
}
#[derive(Deserialize)]
struct PlusLoginResponse {
#[allow(dead_code)]
token: String,
}
#[tracing::instrument(skip(credentials))]
async fn record_plus_login(credentials: &MinecraftCredentials) -> 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::<PlusLoginResponse>().await?;
Ok(())
}
fn generate_plus_server_id() -> String {
let mut rng = rand::thread_rng();
let bytes: Vec<u8> = (0..20).map(|_| rng.r#gen::<u8>()).collect();
bytes.iter().fold(String::new(), |mut output, b| {
let _ = write!(output, "{b:02x}");
output
})
}
#[tracing::instrument(skip(request_fn))]
async fn auth_retry<F>(request_fn: impl Fn() -> F) -> Result<reqwest::Response, reqwest::Error>
where