client_properties.rs
1 use crate::api::{Locale, references::OperatingSystemType}; 2 use anyhow::{Result, anyhow}; 3 use base64::{ 4 Engine as _, engine::general_purpose::STANDARD_NO_PAD as base64nopad, 5 }; 6 use regex::Regex; 7 use reqwest::{Client, header::LOCATION, redirect::Policy}; 8 use scraper::{Html, Selector}; 9 use serde::{Deserialize, Serialize}; 10 use url::Url; 11 use uuid::Uuid; 12 13 /// Mask to make sure we're staying undetected by Discord. 14 /// https://github.com/discord-userdoccers/discord-userdoccers/blob/master/pages/reference.mdx#launch-signature 15 const LAUNCH_SIGNATURE_MASK: u128 = 0b00000000100000000001000000010000000010000001000000001000000000000010000010000001000000000100000000000001000000000000100000000000; 16 17 // Sadly, there's no way to get this externally. 18 pub const ELECTRON_VERSION: &str = "37.6.0"; 19 pub const CHROME_VERSION: &str = "138.0.7204.251"; 20 pub const CHROME_MAJOR: &str = "138"; 21 22 #[derive(Serialize, Deserialize, Clone, Debug)] 23 pub struct ClientProperties { 24 pub os: OperatingSystemType, 25 26 /// The operating system version (kernel version for Linux) 27 pub os_version: String, 28 29 /// The operating system SDK version 30 pub os_sdk_version: String, 31 32 /// The architecture of the operating system. 33 pub os_arch: String, 34 35 /// The architecture of the desktop application. 36 pub app_arch: String, 37 38 /// The browser the client is using. 39 pub browser: String, 40 41 /// The `User-Agent` of Electron. 42 pub browser_user_agent: String, 43 44 /// The version of Electron. 45 pub browser_version: String, 46 47 /// The build number of the client. 48 pub client_build_number: u32, 49 50 /// The native metadata version of the desktop client, only on Windows. 51 pub native_build_number: Option<u32>, 52 53 /// Version of the client currently in use. 54 pub client_version: String, 55 56 /// The alternate event source this request originated from. 57 pub client_event_source: Option<String>, 58 59 /// The focus state of the client when the `Gateway` session was instantiated. 60 pub client_app_state: String, 61 62 /// A client-generated UUID used to identify the client launch 63 pub client_launch_id: String, 64 65 /// A client-generated UUID representing the current persisted analytics 66 /// heartbeat, regenerated every 30 minutes. 67 pub client_heartbeat_session_id: String, 68 69 /// The release channel of the client. 70 pub release_channel: String, 71 72 /// The primary system locale. 73 pub system_locale: String, 74 75 /// The Linux window manager ( env.XDG_CURRENT_DESKTOP ?? "unknown" + "," + env.GDMSESSION ?? "unknown" ) 76 #[serde(skip_serializing_if = "Option::is_none")] 77 pub window_manager: Option<String>, 78 79 /// The Linux distribution (output of lsb_release -ds ) 80 #[serde(skip_serializing_if = "Option::is_none")] 81 pub distro: Option<String>, 82 83 /// Whether the connecting client has modifications (e.g. BetterDiscord) 84 pub has_client_mods: bool, 85 86 /// The launch signature of the client, certain bits are used to encode 87 /// information about the client, specifically whether certain client mods are detected. 88 pub launch_signature: String, 89 } 90 91 impl ClientProperties { 92 pub async fn new() -> Result<Self> { 93 let client_build_number = Self::client_build_number().await?; 94 println!( 95 "cp: downloaded client_build_number = {}", 96 client_build_number 97 ); 98 99 let client_version = Self::client_version().await?; 100 println!("cp: downloaded client_version = {}", client_version); 101 102 let os_version = Self::os_version(); 103 104 Ok(Self { 105 os: OperatingSystemType::get_current(), 106 browser: "Discord Client".into(), 107 release_channel: "stable".into(), 108 client_version: client_version.clone(), 109 os_version: os_version.clone(), 110 os_arch: Self::arch(), 111 app_arch: Self::arch(), 112 system_locale: Locale::system(), 113 has_client_mods: false, 114 client_launch_id: Uuid::new_v4().into(), 115 launch_signature: Self::launch_signature(), 116 browser_user_agent: Self::browser_user_agent(client_version), 117 browser_version: ELECTRON_VERSION.into(), 118 os_sdk_version: Self::os_sdk_version(os_version), 119 client_build_number, 120 // on windows: 121 // https://docs.discord.food/topics/client-distribution#get-latest-distributed-application-manifest 122 // https://updates.discord.com/distributions/app/manifests/latest?channel=stable&platform=win&arch=x64&install_id=0&platform_version=10.0.26100 123 // ^ #metadata_version 124 // native_build_number: Some(66940), 125 #[cfg(target_os = "macos")] 126 native_build_number: None, 127 client_event_source: None, 128 client_heartbeat_session_id: Uuid::new_v4().into(), 129 client_app_state: "focused".into(), 130 // TODO: add implementations for linux 131 #[cfg(not(target_os = "linux"))] 132 distro: None, 133 #[cfg(not(target_os = "linux"))] 134 window_manager: None, 135 }) 136 } 137 138 #[cfg(any(target_os = "macos", target_os = "linux"))] 139 fn os_version() -> String { 140 use std::ffi::CStr; 141 142 unsafe { 143 let mut uts: libc::utsname = std::mem::zeroed(); 144 _ = libc::uname(&mut uts); 145 let release = CStr::from_ptr(uts.release.as_ptr()); 146 release.to_string_lossy().trim().to_string() 147 } 148 } 149 150 #[cfg(target_os = "macos")] 151 fn os_sdk_version(os_version: String) -> String { 152 os_version 153 .split('.') 154 .next() 155 .map(|s| s.to_string()) 156 .expect("can't decide for os_sdk_version") 157 } 158 159 #[cfg(windows)] 160 fn os_version() -> String { 161 use std::mem::MaybeUninit; 162 163 use windows_sys::Win32::System::SystemInformation::{ 164 OSVERSIONINFOW, RtlGetVersion, 165 }; 166 167 unsafe { 168 let mut info = MaybeUninit::<OSVERSIONINFOW>::zeroed(); 169 let mut info = info.assume_init(); 170 info.dwOSVersionInfoSize = std::mem::size_of::<OSVERSIONINFOW>() as u32; 171 172 _ = RtlGetVersion(&mut info); 173 174 format!( 175 "{}.{}.{}", 176 info.dwMajorVersion, info.dwMinorVersion, info.dwBuildNumber 177 ) 178 } 179 } 180 181 fn arch() -> String { 182 match std::env::consts::ARCH { 183 "aarch64" => "arm64", 184 "x86_64" => "x64", 185 "x86" | "i686" => "x86", 186 other => panic!("unsupported arch ({other})"), 187 } 188 .into() 189 } 190 191 fn browser_user_agent(client_version: String) -> String { 192 #[cfg(windows)] 193 const METADATA: &str = "Windows NT 10.0; Win64; x64"; 194 195 #[cfg(target_os = "macos")] 196 const METADATA: &str = "Macintosh; Intel Mac OS X 10_15_7"; 197 198 #[cfg(target_os = "linux")] 199 const METADATA: &str = "X11; Linux x86_64"; 200 201 format!( 202 "Mozilla/5.0 ({METADATA}) AppleWebKit/537.36 (KHTML, like Gecko) discord/{client_version} Chrome/{CHROME_VERSION} Electron/{ELECTRON_VERSION} Safari/537.36" 203 ) 204 } 205 206 fn launch_signature() -> String { 207 let uuid = Uuid::new_v4(); 208 let uuid = uuid.as_u128(); 209 let uuid = uuid & !LAUNCH_SIGNATURE_MASK; 210 Uuid::from_u128(uuid).into() 211 } 212 213 /// Retrieve the latest Discord desktop client version by querying 214 /// the download URL to the API since it contains the version. 215 async fn client_version() -> Result<String> { 216 #[cfg(target_os = "macos")] 217 let url = "https://discord.com/api/download?platform=osx"; 218 219 #[cfg(target_os = "linux")] 220 let url = "https://discord.com/api/download?platform=linux&format=deb"; 221 222 #[cfg(windows)] 223 let url = format!( 224 "https://discord.com/api/downloads/distributions/app/installers/latest?channel=stable&platform=win&arch={}", 225 Self::arch() 226 ); 227 228 let client = Client::builder().redirect(Policy::none()).build()?; 229 let response = client.get(url).send().await?; 230 231 let location = response 232 .headers() 233 .get(LOCATION) 234 .ok_or_else(|| anyhow!("missing redirection"))? 235 .to_str()?; 236 237 let location = Url::parse(location)?; 238 239 let version = location 240 .path_segments() 241 .and_then(|segments| segments.rev().nth(1)) // last-1 242 .ok_or_else(|| anyhow!("redirect URL has no last-1 path segment"))?; 243 244 Ok(version.into()) 245 } 246 247 /// Scrape the latest Discord web client build by reading the HTML. 248 async fn client_build_number() -> Result<u32> { 249 let response = reqwest::get("https://discord.com/app") 250 .await? 251 .text() 252 .await?; 253 254 let html = Html::parse_document(&response); 255 256 let script_body = html 257 .select(&Selector::parse("script").unwrap()) 258 .map(|el| el.text().collect::<String>()) 259 .find(|t| t.contains("window.GLOBAL_ENV")) 260 .ok_or_else(|| anyhow!("GLOBAL_ENV not found on discord.com"))?; 261 262 let build = Regex::new(r#""BUILD_NUMBER"\s*:\s*"(?P<v>\d+)""#)? 263 .captures(&script_body) 264 .and_then(|c| c.name("v").map(|m| m.as_str())) 265 .expect("BUILD_NUMBER to be in GLOBAL_ENV"); 266 267 Ok(build.parse()?) 268 } 269 270 pub fn encode(&self) -> String { 271 let properties = 272 serde_json::to_string(self).expect("client_properties cannot serialize"); 273 274 base64nopad.encode(properties) 275 } 276 }