use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::OnceLock;
const DEVICE_ID_SALT: &str = "xybrid-device-id-v1";
static DEVICE: OnceLock<Device> = OnceLock::new();
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Device {
pub id: String,
pub platform: String,
}
impl Device {
pub fn current() -> &'static Device {
DEVICE.get_or_init(Device::detect)
}
pub fn detect() -> Device {
Device {
id: compute_device_id(),
platform: crate::platform::current_platform().to_string(),
}
}
}
pub fn device_id() -> &'static str {
&Device::current().id
}
fn compute_device_id() -> String {
let raw_id = get_machine_id();
let mut hasher = Sha256::new();
hasher.update(raw_id.as_bytes());
hasher.update(DEVICE_ID_SALT.as_bytes());
format!("{:x}", hasher.finalize())
}
#[cfg(target_os = "macos")]
fn get_machine_id() -> String {
get_macos_machine_id().unwrap_or_else(fallback_machine_id)
}
#[cfg(target_os = "macos")]
fn get_macos_machine_id() -> Option<String> {
let output = std::process::Command::new("sysctl")
.arg("-n")
.arg("kern.uuid")
.output()
.ok()?;
if output.status.success() {
let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !uuid.is_empty() {
return Some(uuid);
}
}
None
}
#[cfg(target_os = "linux")]
fn get_machine_id() -> String {
get_linux_machine_id().unwrap_or_else(fallback_machine_id)
}
#[cfg(target_os = "linux")]
fn get_linux_machine_id() -> Option<String> {
for path in &["/etc/machine-id", "/var/lib/dbus/machine-id"] {
if let Ok(content) = std::fs::read_to_string(path) {
let id = content.trim().to_string();
if !id.is_empty() {
return Some(id);
}
}
}
None
}
#[cfg(target_os = "windows")]
fn get_machine_id() -> String {
get_windows_machine_id().unwrap_or_else(fallback_machine_id)
}
#[cfg(target_os = "windows")]
fn get_windows_machine_id() -> Option<String> {
let output = std::process::Command::new("reg")
.args([
"query",
r"HKLM\SOFTWARE\Microsoft\Cryptography",
"/v",
"MachineGuid",
])
.output()
.ok()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.contains("MachineGuid") {
if let Some(guid) = line.split_whitespace().last() {
if !guid.is_empty() {
return Some(guid.to_string());
}
}
}
}
}
None
}
#[cfg(target_os = "ios")]
fn get_machine_id() -> String {
get_persisted_id().unwrap_or_else(fallback_machine_id)
}
#[cfg(target_os = "android")]
fn get_machine_id() -> String {
get_persisted_id().unwrap_or_else(fallback_machine_id)
}
#[cfg(any(target_os = "ios", target_os = "android"))]
fn get_persisted_id() -> Option<String> {
let cache_dir = crate::get_sdk_cache_dir()
.or_else(dirs::cache_dir)
.or_else(dirs::data_local_dir)?;
let id_file = cache_dir.join(".xybrid-device-id");
if let Ok(content) = std::fs::read_to_string(&id_file) {
let id = content.trim().to_string();
if !id.is_empty() {
return Some(id);
}
}
let new_id = uuid::Uuid::new_v4().to_string();
let _ = std::fs::create_dir_all(&cache_dir);
let _ = std::fs::write(&id_file, &new_id);
Some(new_id)
}
#[cfg(not(any(
target_os = "macos",
target_os = "linux",
target_os = "windows",
target_os = "ios",
target_os = "android",
)))]
fn get_machine_id() -> String {
fallback_machine_id()
}
fn fallback_machine_id() -> String {
let platform = crate::platform::current_platform();
let namespace = uuid::Uuid::NAMESPACE_DNS;
uuid::Uuid::new_v5(&namespace, format!("xybrid-fallback-{platform}").as_bytes()).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn current_returns_cached_instance() {
let a = Device::current();
let b = Device::current();
assert!(
std::ptr::eq(a, b),
"Device::current must return the same singleton"
);
}
#[test]
fn id_is_64_hex_chars() {
let d = Device::current();
assert_eq!(d.id.len(), 64, "SHA-256 hex should be 64 chars");
assert!(
d.id.chars().all(|c| c.is_ascii_hexdigit()),
"id must be hex"
);
}
#[test]
fn platform_matches_current_platform() {
assert_eq!(
Device::current().platform,
crate::platform::current_platform()
);
}
#[test]
fn detect_is_deterministic() {
let a = Device::detect();
let b = Device::detect();
assert_eq!(a, b, "detect() must produce the same Device every time");
}
#[test]
fn salt_changes_hash_output() {
let raw = get_machine_id();
let mut hasher = Sha256::new();
hasher.update(raw.as_bytes());
let unsalted = format!("{:x}", hasher.finalize());
let salted = compute_device_id();
assert_ne!(unsalted, salted, "Salt must change the hash output");
}
#[test]
fn machine_id_not_empty() {
assert!(
!get_machine_id().is_empty(),
"Machine ID must never be empty"
);
}
#[test]
fn device_id_helper_matches_struct() {
assert_eq!(device_id(), Device::current().id.as_str());
}
}