track playtime

This commit is contained in:
Julian Chang 2026-06-29 01:14:10 -04:00
parent 7d1460f885
commit d6dc358e35
No known key found for this signature in database
6 changed files with 151 additions and 22 deletions

58
Cargo.lock generated
View file

@ -389,6 +389,22 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "async-tungstenite"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee88b4c88ac8c9ea446ad43498955750a4bbe64c4392f21ccfe5d952865e318f"
dependencies = [
"atomic-waker",
"futures-core",
"futures-io",
"futures-task",
"futures-util",
"log",
"pin-project-lite",
"tungstenite",
]
[[package]]
name = "async_zip"
version = "0.0.17"
@ -1228,6 +1244,12 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "deflate64"
version = "0.1.9"
@ -3812,6 +3834,7 @@ dependencies = [
"rand 0.8.5",
"regex",
"reqwest",
"reqwest-websocket",
"sanitize-filename",
"sea-orm",
"serde",
@ -4909,6 +4932,24 @@ dependencies = [
"windows-registry 0.2.0",
]
[[package]]
name = "reqwest-websocket"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd5f79b25f7f17a62cc9337108974431a66ae5a723ac0d9fe78ac1cce2027720"
dependencies = [
"async-tungstenite",
"bytes",
"futures-util",
"reqwest",
"thiserror 2.0.12",
"tokio",
"tokio-util",
"tracing",
"tungstenite",
"web-sys",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
@ -7190,6 +7231,23 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.1",
"sha1",
"thiserror 2.0.12",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.2"

View file

@ -115,6 +115,7 @@ reqwest = { version = "0.12", default-features = false, features = [
"gzip",
"brotli",
] }
reqwest-websocket = { version = "0.5", default-features = false }
webbrowser = { version = "=1.0.4" }
# io

View file

@ -118,6 +118,8 @@ async fn initialize_state(handle: &tauri::AppHandle) -> LauncherResult<()> {
tracing::warn!("failed to refresh accounts on startup: {err}");
}
onelauncher_core::store::credentials::spawn_plus_playtime_tracking();
tracing::info!("initialized launcher successfully");
Ok(())
}

View file

@ -325,7 +325,7 @@ export type VersionType =
*/
"old_beta"
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"]}' }
const ARGS_MAP = { 'oneclient':'{"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"copyClusterContent":["source_id","target_id"],"getVersions":[],"isBundleSyncing":[],"installUpdate":[],"cacheArt":["path"],"getBundlesFor":["cluster_id"],"getClustersGroupedByMajor":[],"refreshArt":["path"],"extractBundleOverrides":["bundle_path","cluster_id"],"checkForUpdate":[],"updateBundlePackages":["cluster_id"]}', 'core':'{"getProfileOrDefault":["name"],"getWorlds":["id"],"createCluster":["options"],"setDefaultUser":["uuid"],"refreshAccounts":[],"getPackage":["provider","slug"],"getMultiplePackages":["provider","slugs"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"changeSkin":["access_token","skin_url","skin_variant"],"changeCape":["access_token","cape_uuid"],"installModpack":["modpack","cluster_id"],"open":["input"],"isClusterRunning":["cluster_id"],"killProcess":["pid"],"createSettingsProfile":["name"],"updateClusterProfile":["name","profile"],"getDefaultUser":["fallback"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"syncCluster":["cluster_id"],"fetchMinecraftProfile":["uuid"],"searchPackages":["provider","query"],"removeCape":["access_token"],"getLogs":["id"],"fetchLoggedInProfile":["access_token"],"getClusterById":["id"],"getScreenshots":["id"],"removePackage":["cluster_id","package_hash"],"setClusterStage":["id","stage"],"readSettings":[],"getUsersFromAuthor":["provider","author"],"getUser":["uuid"],"getUsers":[],"updateClusterById":["id","request"],"getPackageBody":["provider","body"],"launchCluster":["id","uuid","search_for_java"],"writeSettings":["setting"],"getLinkedPackages":["cluster_id"],"togglePackage":["cluster_id","package_hash"],"refreshAccount":["uuid"],"getRunningProcesses":[],"getClusters":[],"getLoadersForVersion":["mc_version"],"openMsaLogin":[],"removeCluster":["id"],"removeUser":["uuid"],"getLogByName":["id","name"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"convertUsernameUUID":["username_uuid"],"setDiscordRPCMessage":["message"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getGameVersions":[],"getGlobalProfile":[],"getProcessLogTail":["id","max_lines"],"getRunningProcessesByClusterId":["cluster_id"]}', 'events':'{"message":["event"],"ingress":["event"],"process":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'debug':'{"getOsDistro":[],"getPackageVersion":[],"getArch":[],"getOsVersion":[],"hasDevSettings":[],"getLocale":[],"getFamily":[],"getGitCommitHash":[],"getFullDebugInfoParsedString":[],"getFullDebugInfo":[],"getFullDebugInfoParsed":[],"openDevTools":[],"isInDev":[],"getPlatform":[],"getBuildTimestamp":[],"getType":[]}' }
export type Router = { 'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
@ -380,22 +380,6 @@ removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
setDiscordRPCMessage: (message: string) => Promise<null>,
open: (input: string) => Promise<null> },
'debug': { openDevTools: () => Promise<void>,
isInDev: () => Promise<boolean>,
getArch: () => Promise<string>,
getFamily: () => Promise<string>,
getLocale: () => Promise<string>,
getType: () => Promise<string>,
getPlatform: () => Promise<string>,
getOsVersion: () => Promise<string>,
getOsDistro: () => Promise<string>,
getGitCommitHash: () => Promise<string>,
getBuildTimestamp: () => Promise<string>,
getPackageVersion: () => Promise<string>,
getFullDebugInfo: () => Promise<DebugInfoData>,
getFullDebugInfoParsed: () => Promise<DebugInfoParsedLine[]>,
getFullDebugInfoParsedString: () => Promise<string>,
hasDevSettings: () => Promise<boolean> },
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
getVersions: () => Promise<OnlineClusterManifest>,
@ -412,7 +396,23 @@ copyClusterContent: (sourceId: number, targetId: number) => Promise<CopyClusterR
openCluster: (folderName: string) => Promise<null> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> } };
process: (event: ProcessPayload) => Promise<void> },
'debug': { openDevTools: () => Promise<void>,
isInDev: () => Promise<boolean>,
getArch: () => Promise<string>,
getFamily: () => Promise<string>,
getLocale: () => Promise<string>,
getType: () => Promise<string>,
getPlatform: () => Promise<string>,
getOsVersion: () => Promise<string>,
getOsDistro: () => Promise<string>,
getGitCommitHash: () => Promise<string>,
getBuildTimestamp: () => Promise<string>,
getPackageVersion: () => Promise<string>,
getFullDebugInfo: () => Promise<DebugInfoData>,
getFullDebugInfoParsed: () => Promise<DebugInfoParsedLine[]>,
getFullDebugInfoParsedString: () => Promise<string>,
hasDevSettings: () => Promise<boolean> } };
export type { InferCommandOutput }

View file

@ -67,6 +67,7 @@ thiserror = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
reqwest = { workspace = true }
reqwest-websocket = { workspace = true }
regex = { workspace = true }
sysinfo = { workspace = true }
whoami = { workspace = true }

View file

@ -884,14 +884,13 @@ 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<()> {
async fn record_plus_login(credentials: &MinecraftCredentials) -> anyhow::Result<Option<String>> {
let Some(base_url) = Core::get().plus_backend_url.clone() else {
return Ok(());
return Ok(None);
};
let server_id = generate_plus_server_id();
@ -938,7 +937,75 @@ async fn record_plus_login(credentials: &MinecraftCredentials) -> anyhow::Result
anyhow::bail!("Poly+ login rejected analytics login: status={status}, body={body}");
}
let _response = plus_res.json::<PlusLoginResponse>().await?;
let response = plus_res.json::<PlusLoginResponse>().await?;
Ok(Some(response.token))
}
const PLUS_PLAYTIME_RECONNECT_DELAY: std::time::Duration = std::time::Duration::from_secs(15);
pub fn spawn_plus_playtime_tracking() {
tokio::task::spawn(async move {
loop {
match default_plus_credentials().await {
Ok(Some(credentials)) => {
if let Err(err) = run_plus_playtime_session(&credentials).await {
tracing::warn!("[plus] playtime websocket session ended: {err:?}");
}
}
Ok(None) => {
// No user logged in yet; retry after a delay.
}
Err(err) => {
tracing::warn!(
"[plus] failed to load credentials for playtime tracking: {err:?}"
);
}
}
tokio::time::sleep(PLUS_PLAYTIME_RECONNECT_DELAY).await;
}
});
}
async fn default_plus_credentials() -> LauncherResult<Option<MinecraftCredentials>> {
let state = crate::store::State::get().await?;
let mut store = state.credentials.write().await;
store.get_default().await
}
async fn run_plus_playtime_session(credentials: &MinecraftCredentials) -> anyhow::Result<()> {
use futures::TryStreamExt;
use reqwest_websocket::{Message, RequestBuilderExt};
let Some(base_url) = Core::get().plus_backend_url.clone() else {
return Ok(());
};
let Some(token) = record_plus_login(credentials).await? else {
return Ok(());
};
let ws_url = format!("{}/websocket", base_url.trim_end_matches('/'));
let mut websocket = crate::utils::http::REQWEST_CLIENT
.get(&ws_url)
.bearer_auth(&token)
.upgrade()
.send()
.await?
.into_websocket()
.await?;
tracing::info!(
"[plus] playtime websocket connected for user {}",
credentials.id
);
while let Some(message) = websocket.try_next().await? {
if let Message::Close { .. } = message {
break;
}
}
Ok(())
}