diff --git a/Cargo.lock b/Cargo.lock index afbca4c8..d7a2c47a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index cb22d391..590e0ffe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/apps/oneclient/desktop/src/lib.rs b/apps/oneclient/desktop/src/lib.rs index 41ee34ff..e451bf07 100644 --- a/apps/oneclient/desktop/src/lib.rs +++ b/apps/oneclient/desktop/src/lib.rs @@ -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(()) } diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 61ceae4b..e6e81410 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 = { '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, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, @@ -380,22 +380,6 @@ removeCape: (accessToken: string) => Promise, convertUsernameUUID: (usernameUuid: string) => Promise, setDiscordRPCMessage: (message: string) => Promise, open: (input: string) => Promise }, -'debug': { openDevTools: () => Promise, -isInDev: () => Promise, -getArch: () => Promise, -getFamily: () => Promise, -getLocale: () => Promise, -getType: () => Promise, -getPlatform: () => Promise, -getOsVersion: () => Promise, -getOsDistro: () => Promise, -getGitCommitHash: () => Promise, -getBuildTimestamp: () => Promise, -getPackageVersion: () => Promise, -getFullDebugInfo: () => Promise, -getFullDebugInfoParsed: () => Promise, -getFullDebugInfoParsedString: () => Promise, -hasDevSettings: () => Promise }, 'oneclient': { getClustersGroupedByMajor: () => Promise>, getBundlesFor: (clusterId: number) => Promise, getVersions: () => Promise, @@ -412,7 +396,23 @@ copyClusterContent: (sourceId: number, targetId: number) => Promise Promise }, 'events': { ingress: (event: IngressPayload) => Promise, message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise } }; +process: (event: ProcessPayload) => Promise }, +'debug': { openDevTools: () => Promise, +isInDev: () => Promise, +getArch: () => Promise, +getFamily: () => Promise, +getLocale: () => Promise, +getType: () => Promise, +getPlatform: () => Promise, +getOsVersion: () => Promise, +getOsDistro: () => Promise, +getGitCommitHash: () => Promise, +getBuildTimestamp: () => Promise, +getPackageVersion: () => Promise, +getFullDebugInfo: () => Promise, +getFullDebugInfoParsed: () => Promise, +getFullDebugInfoParsedString: () => Promise, +hasDevSettings: () => Promise } }; export type { InferCommandOutput } diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index ea90cff6..367a3611 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -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 } diff --git a/packages/core/src/store/credentials.rs b/packages/core/src/store/credentials.rs index 22cee9c5..071f2b56 100644 --- a/packages/core/src/store/credentials.rs +++ b/packages/core/src/store/credentials.rs @@ -884,14 +884,13 @@ async fn minecraft_entitlements(token: &str) -> Result anyhow::Result<()> { +async fn record_plus_login(credentials: &MinecraftCredentials) -> anyhow::Result> { 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::().await?; + let response = plus_res.json::().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> { + 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(()) }