feat: Add Discord RPC #23
							
								
								
									
										9
									
								
								bun.lock
									
									
									
									
									
								
							
							
						
						
									
										9
									
								
								bun.lock
									
									
									
									
									
								
							| @@ -34,6 +34,7 @@ | ||||
|         "@sveltejs/kit": "2.22.2", | ||||
|         "@sveltejs/vite-plugin-svelte": "5.1.0", | ||||
|         "@tauri-apps/cli": "2.6.1", | ||||
|         "@types/bun": "^1.2.18", | ||||
|         "@types/crypto-js": "^4.2.2", | ||||
|         "@types/semver": "^7.7.0", | ||||
|         "autoprefixer": "10.4.21", | ||||
| @@ -303,6 +304,8 @@ | ||||
|  | ||||
|     "@tauri-apps/plugin-sql": ["@tauri-apps/plugin-sql@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-JYwIocfsLaDWa41LMiZWuzts7yCJR+EpZPRmgpO7Gd7XiAS9S67dKz306j/k/d9XntB0YopMRBol2OIWMschuA=="], | ||||
|  | ||||
|     "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], | ||||
|  | ||||
|     "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], | ||||
|  | ||||
|     "@types/crypto-js": ["@types/crypto-js@4.2.2", "", {}, "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="], | ||||
| @@ -313,6 +316,8 @@ | ||||
|  | ||||
|     "@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="], | ||||
|  | ||||
|     "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], | ||||
|  | ||||
|     "@types/semver": ["@types/semver@7.7.0", "", {}, "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA=="], | ||||
|  | ||||
|     "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.35.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/type-utils": "8.35.1", "@typescript-eslint/utils": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.35.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg=="], | ||||
| @@ -369,6 +374,8 @@ | ||||
|  | ||||
|     "buffer-builder": ["buffer-builder@0.2.0", "", {}, "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg=="], | ||||
|  | ||||
|     "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], | ||||
|  | ||||
|     "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], | ||||
|  | ||||
|     "caniuse-lite": ["caniuse-lite@1.0.30001726", "", {}, "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw=="], | ||||
| @@ -397,6 +404,8 @@ | ||||
|  | ||||
|     "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], | ||||
|  | ||||
|     "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], | ||||
|  | ||||
|     "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], | ||||
|  | ||||
|     "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], | ||||
|   | ||||
| @@ -48,6 +48,7 @@ | ||||
|     "@sveltejs/kit": "2.22.2", | ||||
|     "@sveltejs/vite-plugin-svelte": "5.1.0", | ||||
|     "@tauri-apps/cli": "2.6.1", | ||||
|     "@types/bun": "^1.2.18", | ||||
|     "@types/crypto-js": "^4.2.2", | ||||
|     "@types/semver": "^7.7.0", | ||||
|     "autoprefixer": "10.4.21", | ||||
|   | ||||
							
								
								
									
										32
									
								
								src-tauri/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										32
									
								
								src-tauri/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -489,7 +489,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" | ||||
| dependencies = [ | ||||
|  "byteorder", | ||||
|  "fnv", | ||||
|  "uuid", | ||||
|  "uuid 1.17.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -843,6 +843,19 @@ dependencies = [ | ||||
|  "windows-sys 0.60.2", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "discord-rich-presence" | ||||
| version = "0.2.5" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b" | ||||
| dependencies = [ | ||||
|  "serde", | ||||
|  "serde_derive", | ||||
|  "serde_json", | ||||
|  "serde_repr", | ||||
|  "uuid 0.8.2", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "dispatch" | ||||
| version = "0.2.0" | ||||
| @@ -1083,8 +1096,10 @@ dependencies = [ | ||||
| name = "ezpplauncher" | ||||
| version = "3.0.0-beta.2" | ||||
| dependencies = [ | ||||
|  "discord-rich-presence", | ||||
|  "hardware-id", | ||||
|  "md5", | ||||
|  "once_cell", | ||||
|  "open", | ||||
|  "reqwest", | ||||
|  "serde", | ||||
| @@ -3802,7 +3817,7 @@ dependencies = [ | ||||
|  "serde", | ||||
|  "serde_json", | ||||
|  "url", | ||||
|  "uuid", | ||||
|  "uuid 1.17.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -4737,7 +4752,7 @@ dependencies = [ | ||||
|  "thiserror 2.0.12", | ||||
|  "time", | ||||
|  "url", | ||||
|  "uuid", | ||||
|  "uuid 1.17.0", | ||||
|  "walkdir", | ||||
| ] | ||||
|  | ||||
| @@ -4968,7 +4983,7 @@ dependencies = [ | ||||
|  "toml", | ||||
|  "url", | ||||
|  "urlpattern", | ||||
|  "uuid", | ||||
|  "uuid 1.17.0", | ||||
|  "walkdir", | ||||
| ] | ||||
|  | ||||
| @@ -5475,6 +5490,15 @@ version = "1.0.4" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | ||||
|  | ||||
| [[package]] | ||||
| name = "uuid" | ||||
| version = "0.8.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" | ||||
| dependencies = [ | ||||
|  "getrandom 0.2.16", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "uuid" | ||||
| version = "1.17.0" | ||||
|   | ||||
| @@ -34,6 +34,8 @@ md5 = "0.8.0" | ||||
| tokio = { version = "1.46.1", features = ["full"] } | ||||
| open = "5.3.2" | ||||
| windows-sys = "0.60.2" | ||||
| discord-rich-presence = "0.2.5" | ||||
| once_cell = "1.21.3" | ||||
|  | ||||
| [target.'cfg(windows)'.dependencies] | ||||
| winreg = "0.55.0" | ||||
|   | ||||
| @@ -11,6 +11,7 @@ use tokio::io::AsyncWriteExt; | ||||
| use tokio::process::Command; | ||||
| use tokio::time::{Duration, sleep}; | ||||
|  | ||||
| use crate::presence; | ||||
| use crate::utils::{ | ||||
|     check_folder_completeness, get_osu_config, get_osu_user_config, get_window_title_by_pid, | ||||
|     set_osu_config_vals, set_osu_user_config_vals, | ||||
| @@ -668,3 +669,47 @@ pub async fn check_for_corruption(folder: String) -> Result<bool, String> { | ||||
|  | ||||
|     Ok(false) | ||||
| } | ||||
|  | ||||
| #[tauri::command] | ||||
| pub async fn presence_connect() -> bool { | ||||
|     presence::connect().await | ||||
| } | ||||
|  | ||||
| #[tauri::command] | ||||
| pub async fn presence_disconnect() { | ||||
|     presence::disconnect().await | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub struct PresenceStatus { | ||||
|     state: Option<String>, | ||||
|     details: Option<String>, | ||||
|     large_image_key: Option<String>, | ||||
| } | ||||
|  | ||||
| #[tauri::command] | ||||
| pub async fn presence_update_status(status: PresenceStatus) { | ||||
|     presence::update_status( | ||||
|         status.state.as_deref(), | ||||
|         status.details.as_deref(), | ||||
|         status.large_image_key.as_deref(), | ||||
|     ); | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub struct PresenceUser { | ||||
|     username: Option<String>, | ||||
|     id: Option<String>, | ||||
| } | ||||
|  | ||||
| #[tauri::command] | ||||
| pub async fn presence_update_user(user: PresenceUser) { | ||||
|     presence::update_user(user.username.as_deref(), user.id.as_deref()); | ||||
| } | ||||
|  | ||||
| #[tauri::command] | ||||
| pub async fn presence_is_connected() -> bool { | ||||
|     presence::has_presence().await | ||||
| } | ||||
|   | ||||
| @@ -2,13 +2,15 @@ | ||||
| use tauri::Manager; | ||||
|  | ||||
| pub mod commands; | ||||
| pub mod presence; | ||||
| pub mod utils; | ||||
| use crate::commands::{ | ||||
|     check_for_corruption, download_ezpp_launcher_update_files, exit, find_osu_installation, | ||||
|     get_beatmapsets_count, get_ezpp_launcher_update_files, get_hwid, get_launcher_version, | ||||
|     get_osu_release_stream, get_osu_skin, get_osu_version, get_platform, get_skins_count, | ||||
|     is_osu_running, open_url_in_browser, replace_ui_files, run_osu, run_osu_updater, | ||||
|     set_osu_config_values, set_osu_user_config_values, valid_osu_folder, | ||||
|     is_osu_running, open_url_in_browser, presence_connect, presence_disconnect, | ||||
|     presence_is_connected, presence_update_status, presence_update_user, replace_ui_files, run_osu, | ||||
|     run_osu_updater, set_osu_config_values, set_osu_user_config_values, valid_osu_folder, | ||||
| }; | ||||
|  | ||||
| #[cfg_attr(mobile, tauri::mobile_entry_point)] | ||||
| @@ -25,7 +27,7 @@ pub fn run() { | ||||
|         })); | ||||
|     } | ||||
|  | ||||
|     builder | ||||
|     let app = builder | ||||
|         .invoke_handler(tauri::generate_handler![ | ||||
|             get_hwid, | ||||
|             find_osu_installation, | ||||
| @@ -47,13 +49,27 @@ pub fn run() { | ||||
|             get_launcher_version, | ||||
|             exit, | ||||
|             get_platform, | ||||
|             check_for_corruption | ||||
|             check_for_corruption, | ||||
|             presence_connect, | ||||
|             presence_disconnect, | ||||
|             presence_update_status, | ||||
|             presence_update_user, | ||||
|             presence_is_connected | ||||
|         ]) | ||||
|         .plugin(tauri_plugin_fs::init()) | ||||
|         .plugin(tauri_plugin_dialog::init()) | ||||
|         .plugin(tauri_plugin_shell::init()) | ||||
|         .plugin(tauri_plugin_cors_fetch::init()) | ||||
|         .plugin(tauri_plugin_sql::Builder::default().build()) | ||||
|         .run(tauri::generate_context!()) | ||||
|         .expect("error while running tauri application"); | ||||
|         .build(tauri::generate_context!()) | ||||
|         .expect("error while building tauri application"); | ||||
|  | ||||
|     app.run(|_app_handle, event| { | ||||
|         if let tauri::RunEvent::ExitRequested { api, .. } = event { | ||||
|             api.prevent_exit(); | ||||
|  | ||||
|             tauri::async_runtime::block_on(presence::disconnect()); | ||||
|             std::process::exit(0); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|   | ||||
							
								
								
									
										231
									
								
								src-tauri/src/presence.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										231
									
								
								src-tauri/src/presence.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,231 @@ | ||||
| use discord_rich_presence::{ | ||||
|     activity::{Activity, Assets, Button, Timestamps}, | ||||
|     DiscordIpc, DiscordIpcClient, | ||||
| }; | ||||
| use once_cell::sync::Lazy; | ||||
| use std::sync::Mutex as StdMutex; | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
| use tokio::sync::{mpsc, oneshot}; | ||||
| use tokio::time::{interval, Duration}; | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub struct PresenceData { | ||||
|     pub state: String, | ||||
|     pub details: String, | ||||
|     pub large_image_key: String, | ||||
|     pub large_image_text: String, | ||||
|     pub small_image_key: Option<String>, | ||||
|     pub small_image_text: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| enum PresenceCommand { | ||||
|     Connect(oneshot::Sender<bool>), | ||||
|     Disconnect(oneshot::Sender<()>), | ||||
|     UpdateData(PresenceData), | ||||
|     IsConnected(oneshot::Sender<bool>), | ||||
| } | ||||
|  | ||||
| struct PresenceActor { | ||||
|     receiver: mpsc::Receiver<PresenceCommand>, | ||||
|     client: Option<DiscordIpcClient>, | ||||
|     data: PresenceData, | ||||
|     start_timestamp: i64, | ||||
| } | ||||
|  | ||||
| impl PresenceActor { | ||||
|     fn new(receiver: mpsc::Receiver<PresenceCommand>) -> Self { | ||||
|         let start = SystemTime::now() | ||||
|             .duration_since(UNIX_EPOCH) | ||||
|             .unwrap() | ||||
|             .as_secs() as i64; | ||||
|  | ||||
|         let data = PresenceData { | ||||
|             state: "Idle in Launcher...".to_string(), | ||||
|             details: "  ".to_string(), | ||||
|             large_image_key: "ezppfarm".to_string(), | ||||
|             large_image_text: "EZPPFarm".to_string(), | ||||
|             small_image_key: None, | ||||
|             small_image_text: None, | ||||
|         }; | ||||
|  | ||||
|         PresenceActor { | ||||
|             receiver, | ||||
|             client: None, | ||||
|             data, | ||||
|             start_timestamp: start, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn run(&mut self) { | ||||
|         let mut update_interval = interval(Duration::from_millis(2500)); | ||||
|  | ||||
|         loop { | ||||
|             tokio::select! { | ||||
|                 Some(cmd) = self.receiver.recv() => { | ||||
|                     match cmd { | ||||
|                         PresenceCommand::Connect(responder) => self.handle_connect(responder).await, | ||||
|                         PresenceCommand::Disconnect(responder) => { | ||||
|                             self.handle_disconnect(responder).await; | ||||
|                         }, | ||||
|                         PresenceCommand::UpdateData(new_data) => { | ||||
|                             self.data = new_data; | ||||
|                         }, | ||||
|                         PresenceCommand::IsConnected(responder) => { | ||||
|                             let _ = responder.send(self.client.is_some()); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 _ = update_interval.tick() => { | ||||
|                     if self.client.is_some() { | ||||
|                         self.handle_update().await; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn handle_connect(&mut self, responder: oneshot::Sender<bool>) { | ||||
|         if self.client.is_some() { | ||||
|             let _ = responder.send(true); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         println!("Actor: Connecting to Discord..."); | ||||
|         match DiscordIpcClient::new("1032772293220384808").map_err(|e| e.to_string()) { | ||||
|             Ok(mut new_client) => { | ||||
|                 if let Err(e) = new_client.connect().map_err(|e| e.to_string()) { | ||||
|                     eprintln!("Failed to connect to Discord: {:?}", e); | ||||
|                     let _ = responder.send(false); | ||||
|                     return; | ||||
|                 } | ||||
|                 self.client = Some(new_client); | ||||
|                 println!("Actor: Connected successfully."); | ||||
|                 self.handle_update().await; | ||||
|                 let _ = responder.send(true); | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 eprintln!("Failed to create Discord client: {:?}", e); | ||||
|                 let _ = responder.send(false); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn handle_disconnect(&mut self, responder: oneshot::Sender<()>) { | ||||
|         if let Some(mut client) = self.client.take() { | ||||
|             println!("Actor: Disconnecting..."); | ||||
|             let _ = client.clear_activity().map_err(|e| e.to_string()); | ||||
|             let _ = client.close().map_err(|e| e.to_string()); | ||||
|             println!("Actor: Disconnected successfully."); | ||||
|         } | ||||
|         let _ = responder.send(()); | ||||
|     } | ||||
|  | ||||
|     async fn handle_update(&mut self) { | ||||
|         if let Some(client) = self.client.as_mut() { | ||||
|             let mut assets = Assets::new() | ||||
|                 .large_image(&self.data.large_image_key) | ||||
|                 .large_text(&self.data.large_image_text); | ||||
|  | ||||
|             if let Some(key) = &self.data.small_image_key { | ||||
|                 assets = assets.small_image(key); | ||||
|             } | ||||
|             if let Some(text) = &self.data.small_image_text { | ||||
|                 assets = assets.small_text(text); | ||||
|             } | ||||
|  | ||||
|             let activity = Activity::new() | ||||
|                 .state(&self.data.state) | ||||
|                 .details(&self.data.details) | ||||
|                 .timestamps(Timestamps::new().start(self.start_timestamp)) | ||||
|                 .assets(assets) | ||||
|                 .buttons(vec![ | ||||
|                     Button::new( | ||||
|                         "Download the Launcher", | ||||
|                         "https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest", | ||||
|                     ), | ||||
|                     Button::new("Join EZPZFarm", "https://ez-pp.farm/discord"), | ||||
|                 ]); | ||||
|  | ||||
|             if let Err(e) = client.set_activity(activity).map_err(|e| e.to_string()) { | ||||
|                 eprintln!("Failed to set activity, disconnecting: {:?}", e); | ||||
|                 if let Some(mut client) = self.client.take() { | ||||
|                     let _ = client.clear_activity(); | ||||
|                     let _ = client.close(); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| static PRESENCE_TX: Lazy<mpsc::Sender<PresenceCommand>> = Lazy::new(|| { | ||||
|     let (tx, rx) = mpsc::channel(10); | ||||
|     let mut actor = PresenceActor::new(rx); | ||||
|     tokio::spawn(async move { actor.run().await }); | ||||
|     tx | ||||
| }); | ||||
|  | ||||
| pub static PRESENCE_DATA: Lazy<StdMutex<PresenceData>> = Lazy::new(|| { | ||||
|     StdMutex::new(PresenceData { | ||||
|         state: "Idle in Launcher...".to_string(), | ||||
|         details: "  ".to_string(), | ||||
|         large_image_key: "ezppfarm".to_string(), | ||||
|         large_image_text: "EZPPFarm".to_string(), | ||||
|         small_image_key: None, | ||||
|         small_image_text: None, | ||||
|     }) | ||||
| }); | ||||
|  | ||||
| pub async fn connect() -> bool { | ||||
|     let (tx, rx) = oneshot::channel(); | ||||
|     if PRESENCE_TX.send(PresenceCommand::Connect(tx)).await.is_ok() { | ||||
|         return rx.await.unwrap_or(false); | ||||
|     } | ||||
|     false | ||||
| } | ||||
|  | ||||
| pub async fn disconnect() { | ||||
|     let (tx, rx) = oneshot::channel(); | ||||
|     if PRESENCE_TX.send(PresenceCommand::Disconnect(tx)).await.is_ok() { | ||||
|         let _ = rx.await; | ||||
|     } else { | ||||
|         println!("Could not send disconnect command; actor may not be running."); | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub async fn has_presence() -> bool { | ||||
|     let (tx, rx) = oneshot::channel(); | ||||
|     if PRESENCE_TX.send(PresenceCommand::IsConnected(tx)).await.is_ok() { | ||||
|         return rx.await.unwrap_or(false); | ||||
|     } | ||||
|     false | ||||
| } | ||||
|  | ||||
| pub fn update_status(state: Option<&str>, details: Option<&str>, large_image_key: Option<&str>) { | ||||
|     let mut data = PRESENCE_DATA.lock().unwrap(); | ||||
|     if let Some(s) = state { | ||||
|         data.state = s.to_string(); | ||||
|     } | ||||
|     if let Some(d) = details { | ||||
|         data.details = d.to_string(); | ||||
|     } | ||||
|     if let Some(img) = large_image_key { | ||||
|         data.large_image_key = img.to_string(); | ||||
|     } | ||||
|     let data_clone = data.clone(); | ||||
|     let tx = PRESENCE_TX.clone(); | ||||
|     tokio::spawn(async move { | ||||
|         let _ = tx.send(PresenceCommand::UpdateData(data_clone)).await; | ||||
|     }); | ||||
| } | ||||
|  | ||||
| pub fn update_user(username: Option<&str>, id: Option<&str>) { | ||||
|     let mut data = PRESENCE_DATA.lock().unwrap(); | ||||
|     data.small_image_key = id.map(|id_str| format!("https://a.ez-pp.farm/{}", id_str)); | ||||
|     data.small_image_text = username.map(|s| s.to_string()); | ||||
|     let data_clone = data.clone(); | ||||
|     let tx = PRESENCE_TX.clone(); | ||||
|     tokio::spawn(async move { | ||||
|         let _ = tx.send(PresenceCommand::UpdateData(data_clone)).await; | ||||
|     }); | ||||
| } | ||||
| @@ -1,4 +1,9 @@ | ||||
| import type { EZPPUser, EZPPUserInfoResponse, EZPPUserResponse } from '@/types'; | ||||
| import type { | ||||
|   EZPPUser, | ||||
|   EZPPUserInfoResponse, | ||||
|   EZPPUserResponse, | ||||
|   EZPPUSerStatusResponse, | ||||
| } from '@/types'; | ||||
| import { betterFetch } from '@better-fetch/fetch'; | ||||
|  | ||||
| const BANCHO_ENDPOINT = 'https://c.ez-pp.farm/'; | ||||
| @@ -54,12 +59,12 @@ export const ezppfarm = { | ||||
|     } | ||||
|     return request.data; | ||||
|   }, | ||||
|   getUserInfo: async (userId: number) => { | ||||
|   getUserInfo: async (userId: number, scope: 'all' | 'info' | 'stats' = 'all') => { | ||||
|     const request = await betterFetch<EZPPUserInfoResponse>(`${API_ENDPOINT}v1/get_player_info`, { | ||||
|       timeout, | ||||
|       query: { | ||||
|         id: userId, | ||||
|         scope: 'all', | ||||
|         scope, | ||||
|       }, | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
| @@ -68,4 +73,20 @@ export const ezppfarm = { | ||||
|     }); | ||||
|     return request.error ? undefined : request.data; | ||||
|   }, | ||||
|   getUserStatus: async (userId: number) => { | ||||
|     const request = await betterFetch<EZPPUSerStatusResponse>( | ||||
|       `${API_ENDPOINT}v1/get_player_status`, | ||||
|       { | ||||
|         timeout, | ||||
|         query: { | ||||
|           id: userId, | ||||
|         }, | ||||
|         headers: { | ||||
|           'Content-Type': 'application/json', | ||||
|           'User-Agent': 'EZPPLauncher', | ||||
|         }, | ||||
|       } | ||||
|     ); | ||||
|     return request.error ? undefined : request.data; | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -9,6 +9,9 @@ export const currentView = writable<Component>(Loading); | ||||
| export const launcherVersion = writable<string>(''); | ||||
| export const newVersion = writable<Release | undefined>(undefined); | ||||
|  | ||||
| export const discordPresence = writable<boolean>(false); | ||||
| export const presenceLoading = writable<boolean>(false); | ||||
|  | ||||
| export const currentLoadingInfo = writable<string>('Initializing...'); | ||||
|  | ||||
| export const firstStartup = writable<boolean>(false); | ||||
|   | ||||
							
								
								
									
										19
									
								
								src/lib/presence.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/lib/presence.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import { invoke } from '@tauri-apps/api/core'; | ||||
|  | ||||
| export const connect = async () => await invoke('presence_connect'); | ||||
| export const disconnect = async () => await invoke('presence_disconnect'); | ||||
| export const updateStatus = async (status: { | ||||
|   state: string; | ||||
|   details: string; | ||||
|   largeImageKey?: string; | ||||
| }) => | ||||
|   await invoke('presence_update_status', { | ||||
|     status: { | ||||
|       state: status.state, | ||||
|       details: status.details, | ||||
|       largeImageKey: status.largeImageKey, | ||||
|     }, | ||||
|   }); | ||||
| export const updateUser = async (user: { username: string; id?: string | null }) => | ||||
|   await invoke('presence_update_user', { user: { username: user.username, id: user.id } }); | ||||
| export const isConnected = async () => await invoke<boolean>('presence_is_connected'); | ||||
| @@ -185,3 +185,65 @@ export type Release = { | ||||
|     browser_download_url: string; | ||||
|   }[]; | ||||
| }; | ||||
|  | ||||
| export type EZPPUSerStatusResponse = EZPPUserOfflineStatus | EZPPUserOnlineStatus; | ||||
|  | ||||
| type EZPPUserOfflineStatus = { | ||||
|   status: string; | ||||
|   player_status: { | ||||
|     online: false; | ||||
|     last_seen: number; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| type EZPPUserOnlineStatus = { | ||||
|   status: string; | ||||
|   player_status: { | ||||
|     online: true; | ||||
|     login_time: number; | ||||
|     status: { | ||||
|       action: EZPPActionStatus; | ||||
|       info_text: string; | ||||
|       mode: number; | ||||
|       mods: number; | ||||
|       beatmap: EZPPUserBeatmapStatus | null; | ||||
|     }; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| type EZPPUserBeatmapStatus = { | ||||
|   md5: string; | ||||
|   id: number; | ||||
|   set_id: number; | ||||
|   artist: string; | ||||
|   title: string; | ||||
|   version: string; | ||||
|   creator: string; | ||||
|   last_update: string; | ||||
|   total_length: number; | ||||
|   max_combo: number; | ||||
|   status: number; | ||||
|   plays: number; | ||||
|   passes: number; | ||||
|   mode: number; | ||||
|   bpm: number; | ||||
|   cs: number; | ||||
|   od: number; | ||||
|   ar: number; | ||||
|   hp: number; | ||||
|   diff: number; | ||||
| }; | ||||
|  | ||||
| export enum EZPPActionStatus { | ||||
|   AFK = 1, | ||||
|   PLAYING = 2, | ||||
|   EDITING = 3, | ||||
|   MODDING = 4, | ||||
|   MULTIPLAYER_SELECT = 5, | ||||
|   WATCHING = 6, | ||||
|   TESTING = 8, | ||||
|   SUBMITTING = 9, | ||||
|   MULTIPLAYER_IDLE = 11, | ||||
|   MULTIPLAYER_PLAYING = 12, | ||||
|   DIRECT = 13, | ||||
| } | ||||
|   | ||||
| @@ -89,3 +89,14 @@ export const formatBytes = (bytes: number, decimals = 2) => { | ||||
| export const openURL = async (url: string) => { | ||||
|   await invoke('open_url_in_browser', { url }); | ||||
| }; | ||||
|  | ||||
| export const urlIsValidImage = async (url: string) => { | ||||
|   try { | ||||
|     const request = await fetch(url); | ||||
|     if (!request.ok) return false; | ||||
|     const contentType = request.headers.get('content-type'); | ||||
|     return contentType?.startsWith('image/'); | ||||
|   } catch { | ||||
|     return false; | ||||
|   } | ||||
| }; | ||||
|   | ||||
| @@ -8,11 +8,13 @@ | ||||
|     beatmapSets, | ||||
|     currentSkin, | ||||
|     currentView, | ||||
|     discordPresence, | ||||
|     launcherVersion, | ||||
|     launching, | ||||
|     newVersion, | ||||
|     osuBuild, | ||||
|     osuStream, | ||||
|     presenceLoading, | ||||
|     serverConnectionFails, | ||||
|     serverPing, | ||||
|     skins, | ||||
| @@ -46,6 +48,7 @@ | ||||
|     numberHumanReadable, | ||||
|     openURL, | ||||
|     releaseStreamToReadable, | ||||
|     urlIsValidImage, | ||||
|   } from '@/utils'; | ||||
|   import { fade, scale } from 'svelte/transition'; | ||||
|   import { Checkbox } from '@/components/ui/checkbox'; | ||||
| @@ -96,6 +99,8 @@ | ||||
|   import { getCurrentWindow } from '@tauri-apps/api/window'; | ||||
|   import { ezppfarm } from '@/api/ezpp'; | ||||
|   import Hearts from '@/components/ui/effects/Hearts.svelte'; | ||||
|   import { EZPPActionStatus } from '@/types'; | ||||
|   import * as presence from '@/presence'; | ||||
|  | ||||
|   let selectedTab = $state('home'); | ||||
|   let progress = $state(-1); | ||||
| @@ -313,9 +318,130 @@ | ||||
|       await replaceUIFiles(osuPath, false); | ||||
|       await new Promise((res) => setTimeout(res, 1000)); | ||||
|       await getCurrentWindow().hide(); | ||||
|  | ||||
|       let presenceUpdater: number | undefined = undefined; | ||||
|  | ||||
|       const isPresenceConnected = await presence.isConnected(); | ||||
|  | ||||
|       if ($discordPresence && isPresenceConnected) { | ||||
|         let osuDetected = false; | ||||
|         presenceUpdater = window.setInterval(async () => { | ||||
|           if (!osuDetected) { | ||||
|             const osuRunning = await isOsuRunning(); | ||||
|             if (osuRunning) osuDetected = true; | ||||
|             return; | ||||
|           } | ||||
|           if ($currentUser) { | ||||
|             const userStats = await ezppfarm.getUserInfo($currentUser.id, 'stats'); | ||||
|             const userStatus = await ezppfarm.getUserStatus($currentUser.id); | ||||
|             if (userStatus?.player_status.online) { | ||||
|               let largeImageKey = 'ezppfarm'; | ||||
|               let details = 'Idle...'; | ||||
|               let state = | ||||
|                 userStatus.player_status.status.info_text.length > 0 | ||||
|                   ? userStatus.player_status.status.info_text | ||||
|                   : '  '; | ||||
|  | ||||
|               const gamemode = getModeAndTypeFromGamemode(userStatus.player_status.status.mode); | ||||
|               const gamemodeName = getGamemodeName( | ||||
|                 modeIntToStr(gamemode.mode), | ||||
|                 typeIntToStr(gamemode.type) | ||||
|               ); | ||||
|  | ||||
|               switch (userStatus.player_status.status.action) { | ||||
|                 case EZPPActionStatus.AFK: | ||||
|                   details = 'AFK...'; | ||||
|                   state = '  '; | ||||
|                   break; | ||||
|                 case EZPPActionStatus.PLAYING: | ||||
|                   details = 'Playing...'; | ||||
|                   break; | ||||
|                 case EZPPActionStatus.EDITING: | ||||
|                   details = 'Editing...'; | ||||
|                   break; | ||||
|                 case EZPPActionStatus.MODDING: | ||||
|                   details = 'Modding...'; | ||||
|                   break; | ||||
|                 case EZPPActionStatus.MULTIPLAYER_SELECT: | ||||
|                   details = 'Multiplayer: Selecting a Beatmap...'; | ||||
|                   state = '  '; | ||||
|                   break; | ||||
|                 case EZPPActionStatus.WATCHING: | ||||
|                   details = 'Watching...'; | ||||
|                   break; | ||||
|                 case EZPPActionStatus.TESTING: | ||||
|                   details = 'Testing...'; | ||||
|                   break; | ||||
|                 case EZPPActionStatus.SUBMITTING: | ||||
|                   details = 'Submitting...'; | ||||
|                   break; | ||||
|                 case EZPPActionStatus.MULTIPLAYER_IDLE: | ||||
|                   details = 'Multiplayer: Idle...'; | ||||
|                   state = '  '; | ||||
|                   break; | ||||
|                 case EZPPActionStatus.MULTIPLAYER_PLAYING: | ||||
|                   details = 'Multiplayer: Playing...'; | ||||
|                   break; | ||||
|                 case EZPPActionStatus.DIRECT: | ||||
|                   details = 'Browsing osu!direct...'; | ||||
|                   state = '  '; | ||||
|                   break; | ||||
|               } | ||||
|  | ||||
|               if (userStatus.player_status.status.beatmap !== null) { | ||||
|                 const beatmapCoverImage = `https://assets.ppy.sh/beatmaps/${userStatus.player_status.status.beatmap.set_id}/covers/list@2x.jpg`; | ||||
|                 const isValidImage = await urlIsValidImage(beatmapCoverImage); | ||||
|                 if (isValidImage) largeImageKey = beatmapCoverImage; | ||||
|               } | ||||
|  | ||||
|               details = `[${gamemodeName}] ${details}`; | ||||
|               try { | ||||
|                 const currentModeStats = | ||||
|                   userStats?.player.stats[userStatus.player_status.status.mode]; | ||||
|                 let username = $currentUser.name; | ||||
|  | ||||
|                 if (currentModeStats && currentModeStats.rank > 0) | ||||
|                   username += ` (#${currentModeStats.rank})`; | ||||
|  | ||||
|                 await Promise.all([ | ||||
|                   presence.updateUser({ | ||||
|                     username, | ||||
|                     id: $currentUser.id.toFixed(), | ||||
|                   }), | ||||
|                   presence.updateStatus({ | ||||
|                     details, | ||||
|                     state, | ||||
|                     largeImageKey, | ||||
|                   }), | ||||
|                 ]); | ||||
|               } catch {} | ||||
|             } | ||||
|           } | ||||
|         }, 1000 * 2); | ||||
|       } | ||||
|  | ||||
|       await runOsu(osuPath, true); | ||||
|  | ||||
|       launchInfo = 'Cleaning up...'; | ||||
|       await getCurrentWindow().show(); | ||||
|       if (presenceUpdater) { | ||||
|         window.clearInterval(presenceUpdater); | ||||
|         console.log('clearing discord presence...'); | ||||
|         try { | ||||
|           await Promise.all([ | ||||
|             presence.updateUser({ | ||||
|               username: '  ', | ||||
|               id: null, | ||||
|             }), | ||||
|             presence.updateStatus({ | ||||
|               details: '  ', | ||||
|               state: 'Idle in Launcher...', | ||||
|               largeImageKey: 'ezppfarm', | ||||
|             }), | ||||
|           ]); | ||||
|         } catch {} | ||||
|         console.log('discord presence cleared...'); | ||||
|       } | ||||
|       await new Promise((res) => setTimeout(res, 1000)); | ||||
|       await replaceUIFiles(osuPath, true); | ||||
|  | ||||
| @@ -953,13 +1079,13 @@ | ||||
|             ></Checkbox> | ||||
|  | ||||
|             <div class="flex flex-col"> | ||||
|               <Label class="text-sm" for="setting-cursor-smoothening">Reduce Animations</Label> | ||||
|               <Label class="text-sm" for="setting-reduce-animations">Reduce Animations</Label> | ||||
|               <div class="text-muted-foreground text-xs"> | ||||
|                 Disables some animations in the Launcher to improve performance on low-end devices. | ||||
|               </div> | ||||
|             </div> | ||||
|             <Checkbox | ||||
|               id="setting-cursor-smoothening" | ||||
|               id="setting-reduce-animations" | ||||
|               checked={$reduceAnimations} | ||||
|               onCheckedChange={async (e) => { | ||||
|                 reduceAnimations.set(e); | ||||
| @@ -967,6 +1093,26 @@ | ||||
|               }} | ||||
|               class="flex items-center justify-center w-5 h-5" | ||||
|             ></Checkbox> | ||||
|  | ||||
|             <div class="flex flex-col"> | ||||
|               <Label class="text-sm" for="setting-rich-presence">Discord Rich Presence</Label> | ||||
|               <div class="text-muted-foreground text-xs"> | ||||
|                 Let other discord users show what you are doing right now 👀 | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="relative"> | ||||
|               {#if $presenceLoading} | ||||
|                 <div class="-left-8 absolute" transition:fade> | ||||
|                   <LoaderCircle class="animate-spin" /> | ||||
|                 </div> | ||||
|               {/if} | ||||
|               <Checkbox | ||||
|                 id="setting-rich-presence" | ||||
|                 bind:checked={$discordPresence} | ||||
|                 disabled={$presenceLoading} | ||||
|                 class="flex items-center justify-center w-5 h-5" | ||||
|               ></Checkbox> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div | ||||
|             class="grid grid-cols-[0.7fr_auto] gap-y-5 items-center border-theme-800 pl-6 pr-5 pb-4" | ||||
|   | ||||
| @@ -4,7 +4,14 @@ | ||||
|  | ||||
|   import Titlebar from '@/components/ui/titlebar/titlebar.svelte'; | ||||
|   import * as AlertDialog from '@/components/ui/alert-dialog'; | ||||
|   import { currentLoadingInfo, firstStartup, launcherVersion, setupValues } from '@/global'; | ||||
|   import { | ||||
|     currentLoadingInfo, | ||||
|     discordPresence, | ||||
|     firstStartup, | ||||
|     launcherVersion, | ||||
|     presenceLoading, | ||||
|     setupValues, | ||||
|   } from '@/global'; | ||||
|   import { onMount } from 'svelte'; | ||||
|   import OsuCursor from '@/components/ui/osu-cursor/OsuCursor.svelte'; | ||||
|   import { | ||||
| @@ -20,6 +27,7 @@ | ||||
|   import { userAuth } from '@/userAuthentication'; | ||||
|   import { exit, getLauncherVersion, getPlatform } from '@/osuUtil'; | ||||
|   import Button from '@/components/ui/button/button.svelte'; | ||||
|   import * as presence from '@/presence'; | ||||
|  | ||||
|   import '@fontsource/sora'; | ||||
|   import '@fontsource/space-mono'; | ||||
| @@ -92,17 +100,40 @@ | ||||
|     const config_cursor_smoothening = $userSettings.value('cursor_smoothening'); | ||||
|     const config_reduce_animations = $userSettings.value('reduce_animations'); | ||||
|     const config_osu_installation_path = $userSettings.value('osu_installation_path'); | ||||
|     const config_discord_presence = $userSettings.value('discord_presence'); | ||||
|  | ||||
|     patch.set(config_patching.get(true)); | ||||
|     customCursor.set(config_custom_cursor.get(true)); | ||||
|     cursorSmoothening.set(config_cursor_smoothening.get(true)); | ||||
|     reduceAnimations.set(config_reduce_animations.get(false)); | ||||
|     osuInstallationPath.set(config_osu_installation_path.get('')); | ||||
|     discordPresence.set(config_discord_presence.get(true)); | ||||
|  | ||||
|     patch.subscribe((val) => config_patching.set(val)); | ||||
|     customCursor.subscribe((val) => config_custom_cursor.set(val)); | ||||
|     cursorSmoothening.subscribe((val) => config_cursor_smoothening.set(val)); | ||||
|     reduceAnimations.subscribe((val) => config_reduce_animations.set(val)); | ||||
|     discordPresence.subscribe(async (val) => { | ||||
|       config_discord_presence.set(val); | ||||
|       try { | ||||
|         presenceLoading.set(true); | ||||
|         if (val) { | ||||
|           await presence.connect(); | ||||
|         } else { | ||||
|           await presence.disconnect(); | ||||
|         } | ||||
|         presenceLoading.set(false); | ||||
|       } catch {} | ||||
|     }); | ||||
|  | ||||
|     try { | ||||
|       if ($discordPresence) { | ||||
|         currentLoadingInfo.set('Connecting to Discord RPC...'); | ||||
|         presenceLoading.set(true); | ||||
|         await presence.connect(); | ||||
|         presenceLoading.set(false); | ||||
|       } | ||||
|     } catch {} | ||||
|  | ||||
|     firstStartup.set(isFirstStartup); | ||||
|   }); | ||||
|   | ||||
							
								
								
									
										24
									
								
								tests/imageCheck.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								tests/imageCheck.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import { expect, test } from 'bun:test'; | ||||
|  | ||||
| const urlIsValidImage = async (url: string) => { | ||||
|   try { | ||||
|     const request = await fetch(url); | ||||
|     if (!request.ok) return false; | ||||
|     const contentType = request.headers.get('content-type'); | ||||
|     return contentType?.startsWith('image/'); | ||||
|   } catch { | ||||
|     return false; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| test('image check', async () => { | ||||
|   const imageUrl = 'https://assets.ppy.sh/beatmaps/1/covers/list@2x.jpg'; | ||||
|   const imageCheckResult = await urlIsValidImage(imageUrl); | ||||
|   expect(imageCheckResult).toBe(true); | ||||
| }); | ||||
|  | ||||
| test('image check fail', async () => { | ||||
|   const imageUrl = 'https://assets.ppy.sh/beatmaps/0/covers/list@2x.jpg'; | ||||
|   const imageCheckResult = await urlIsValidImage(imageUrl); | ||||
|   expect(imageCheckResult).toBe(false); | ||||
| }); | ||||
		Reference in New Issue
	
	Block a user