/ src / api / references / client_properties.rs
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  }