#![cfg(not(target_arch = "wasm32"))]
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const DEVICE_FILE_NAME: &str = "openrtc_device.json";
const DESKTOP_FALLBACK_DEVICE_NAME: &str = "Desktop Device";
#[cfg(target_os = "ios")]
const MOBILE_IOS_FALLBACK_DEVICE_NAME: &str = "iOS Device";
#[cfg(target_os = "android")]
const MOBILE_ANDROID_FALLBACK_DEVICE_NAME: &str = "Android Device";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum NativeDeviceNameSource {
UserProvided,
EnvironmentOverride,
SystemDetected,
Fallback,
}
impl Default for NativeDeviceNameSource {
fn default() -> Self {
Self::Fallback
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NativeSystemDeviceInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uuid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manufacturer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serial: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub android_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device_name: Option<String>,
pub platform_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeDeviceIdentity {
pub device_id: String,
pub device_name: String,
pub created_at_ms: i64,
pub updated_at_ms: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name_source: Option<NativeDeviceNameSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_info: Option<NativeSystemDeviceInfo>,
}
pub fn default_platform_type() -> &'static str {
#[cfg(any(target_os = "ios", target_os = "android"))]
{
"mobile"
}
#[cfg(not(any(target_os = "ios", target_os = "android")))]
{
"desktop"
}
}
fn first_non_empty_env(keys: &[&str]) -> Option<String> {
for key in keys {
if let Ok(value) = std::env::var(key) {
let value = value.trim().to_string();
if !value.is_empty() {
return Some(value);
}
}
}
None
}
fn env_override_device_name() -> Option<String> {
first_non_empty_env(&["OPENRTC_DEVICE_NAME", "DEVICE_NAME"])
}
fn read_trimmed_file(path: &Path) -> Option<String> {
let value = std::fs::read_to_string(path).ok()?;
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
}
fn trim_optional(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
})
}
fn fallback_device_name() -> String {
#[cfg(any(target_os = "ios", target_os = "android"))]
{
return match default_platform_type() {
"mobile" => {
#[cfg(target_os = "ios")]
{
MOBILE_IOS_FALLBACK_DEVICE_NAME.to_string()
}
#[cfg(target_os = "android")]
{
MOBILE_ANDROID_FALLBACK_DEVICE_NAME.to_string()
}
}
_ => DESKTOP_FALLBACK_DEVICE_NAME.to_string(),
};
}
#[cfg(not(any(target_os = "ios", target_os = "android")))]
{
DESKTOP_FALLBACK_DEVICE_NAME.to_string()
}
}
fn is_known_fallback_name(name: &str) -> bool {
let normalized = name.trim().to_ascii_lowercase();
if normalized.is_empty() {
return true;
}
if normalized == DESKTOP_FALLBACK_DEVICE_NAME.to_ascii_lowercase() {
return true;
}
#[cfg(target_os = "ios")]
if normalized == MOBILE_IOS_FALLBACK_DEVICE_NAME {
return true;
}
#[cfg(target_os = "android")]
if normalized == MOBILE_ANDROID_FALLBACK_DEVICE_NAME {
return true;
}
false
}
#[cfg(not(any(target_os = "ios", target_os = "android")))]
fn default_desktop_device_name() -> Option<String> {
use std::process::Command;
let name = first_non_empty_env(&["HOSTNAME", "COMPUTERNAME"]);
if name.is_some() {
return name;
}
#[cfg(target_os = "macos")]
{
if let Ok(output) = Command::new("scutil")
.arg("--get")
.arg("ComputerName")
.output()
{
if output.status.success() {
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !value.is_empty() {
return Some(value);
}
}
}
}
if let Some(value) = read_trimmed_file(Path::new("/etc/hostname")) {
return Some(value);
}
if let Ok(output) = Command::new("hostname").output() {
if output.status.success() {
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !value.is_empty() {
return Some(value);
}
}
}
None
}
#[cfg(target_os = "ios")]
fn default_mobile_device_name() -> Option<String> {
first_non_empty_env(&["DEVICE_NAME", "SIMULATOR_DEVICE_NAME", "HOSTNAME"])
}
#[cfg(target_os = "android")]
fn default_mobile_device_name() -> Option<String> {
first_non_empty_env(&["DEVICE_NAME", "MODEL", "ANDROID_MODEL", "HOSTNAME"])
}
#[cfg(target_os = "linux")]
fn linux_system_info() -> NativeSystemDeviceInfo {
let base = Path::new("/sys/class/dmi/id");
NativeSystemDeviceInfo {
uuid: trim_optional(read_trimmed_file(&base.join("product_uuid"))),
manufacturer: trim_optional(read_trimmed_file(&base.join("sys_vendor"))),
model: trim_optional(read_trimmed_file(&base.join("product_name"))),
serial: trim_optional(read_trimmed_file(&base.join("product_serial"))),
android_id: None,
device_name: default_desktop_device_name(),
platform_type: default_platform_type().to_string(),
}
}
#[cfg(target_os = "macos")]
fn macos_system_info() -> NativeSystemDeviceInfo {
use std::process::Command;
fn parse_field(root: &serde_json::Value, key: &str) -> Option<String> {
let array = root.get("SPHardwareDataType")?.as_array()?;
let first = array.first()?;
trim_optional(first.get(key)?.as_str().map(str::to_string))
}
let output = Command::new("system_profiler")
.args(["SPHardwareDataType", "-json"])
.output()
.ok();
let parsed = output
.filter(|output| output.status.success())
.and_then(|output| serde_json::from_slice::<serde_json::Value>(&output.stdout).ok());
NativeSystemDeviceInfo {
uuid: parsed
.as_ref()
.and_then(|value| parse_field(value, "platform_UUID")),
manufacturer: Some("Apple Inc.".to_string()),
model: parsed
.as_ref()
.and_then(|value| parse_field(value, "machine_model")),
serial: parsed
.as_ref()
.and_then(|value| parse_field(value, "serial_number")),
android_id: None,
device_name: default_desktop_device_name(),
platform_type: default_platform_type().to_string(),
}
}
#[cfg(target_os = "windows")]
fn windows_system_info() -> NativeSystemDeviceInfo {
use wmi::{COMLibrary, WMIConnection};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Win32ComputerSystemProduct {
uuid: Option<String>,
vendor: Option<String>,
name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Win32Bios {
serial_number: Option<String>,
}
let mut info = NativeSystemDeviceInfo {
uuid: None,
manufacturer: None,
model: None,
serial: None,
android_id: None,
device_name: trim_optional(first_non_empty_env(&[
"COMPUTERNAME",
"HOSTNAME",
"DEVICE_NAME",
])),
platform_type: default_platform_type().to_string(),
};
let connection = COMLibrary::new()
.ok()
.and_then(|com| WMIConnection::new(com).ok());
if let Some(connection) = connection {
if let Ok(mut rows) = connection.raw_query::<Win32ComputerSystemProduct>(
"SELECT UUID, Vendor, Name FROM Win32_ComputerSystemProduct",
) {
if let Some(row) = rows.pop() {
info.uuid = trim_optional(row.uuid);
info.manufacturer = trim_optional(row.vendor);
info.model = trim_optional(row.name);
}
}
if let Ok(mut rows) =
connection.raw_query::<Win32Bios>("SELECT SerialNumber FROM Win32_BIOS")
{
if let Some(row) = rows.pop() {
info.serial = trim_optional(row.serial_number);
}
}
}
info
}
#[cfg(target_os = "ios")]
fn ios_system_info() -> NativeSystemDeviceInfo {
NativeSystemDeviceInfo {
uuid: None,
manufacturer: Some("Apple Inc.".to_string()),
model: None,
serial: None,
android_id: None,
device_name: default_mobile_device_name(),
platform_type: default_platform_type().to_string(),
}
}
#[cfg(target_os = "android")]
fn android_system_info() -> NativeSystemDeviceInfo {
NativeSystemDeviceInfo {
uuid: None,
manufacturer: trim_optional(first_non_empty_env(&["MANUFACTURER", "ANDROID_BRAND"])),
model: trim_optional(first_non_empty_env(&["MODEL", "ANDROID_MODEL"])),
serial: None,
android_id: trim_optional(first_non_empty_env(&["ANDROID_ID"])),
device_name: default_mobile_device_name(),
platform_type: default_platform_type().to_string(),
}
}
pub fn current_system_device_info() -> NativeSystemDeviceInfo {
#[cfg(target_os = "linux")]
{
return linux_system_info();
}
#[cfg(target_os = "macos")]
{
return macos_system_info();
}
#[cfg(target_os = "windows")]
{
return windows_system_info();
}
#[cfg(target_os = "ios")]
{
return ios_system_info();
}
#[cfg(target_os = "android")]
{
return android_system_info();
}
}
pub fn default_device_name() -> String {
if let Some(value) = env_override_device_name() {
return value;
}
let system_info = current_system_device_info();
if let Some(value) = system_info.device_name {
return value;
}
fallback_device_name()
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or(0)
}
fn device_file_path(base_dir: &Path) -> PathBuf {
base_dir.join(DEVICE_FILE_NAME)
}
fn infer_existing_name_source(
identity: &NativeDeviceIdentity,
system_info: &NativeSystemDeviceInfo,
) -> NativeDeviceNameSource {
if let Some(source) = identity.name_source.clone() {
return source;
}
if is_known_fallback_name(&identity.device_name) {
return NativeDeviceNameSource::Fallback;
}
if system_info.device_name.as_deref() == Some(identity.device_name.as_str()) {
return NativeDeviceNameSource::SystemDetected;
}
NativeDeviceNameSource::UserProvided
}
fn resolved_name_candidate(
preferred_name: Option<&str>,
system_info: &NativeSystemDeviceInfo,
) -> (String, NativeDeviceNameSource) {
if let Some(name) = preferred_name
.map(str::trim)
.filter(|name| !name.is_empty())
{
return (name.to_string(), NativeDeviceNameSource::UserProvided);
}
if let Some(name) = env_override_device_name() {
return (name, NativeDeviceNameSource::EnvironmentOverride);
}
if let Some(name) = system_info.device_name.clone() {
return (name, NativeDeviceNameSource::SystemDetected);
}
(fallback_device_name(), NativeDeviceNameSource::Fallback)
}
fn should_update_stored_name(source: &NativeDeviceNameSource) -> bool {
matches!(
source,
NativeDeviceNameSource::Fallback
| NativeDeviceNameSource::SystemDetected
| NativeDeviceNameSource::EnvironmentOverride
)
}
pub async fn load_or_create(
base_dir: &Path,
preferred_name: Option<&str>,
) -> anyhow::Result<NativeDeviceIdentity> {
if !base_dir.exists() {
tokio::fs::create_dir_all(base_dir)
.await
.with_context(|| format!("failed to create device base dir at {:?}", base_dir))?;
}
let system_info = current_system_device_info();
let path = device_file_path(base_dir);
if path.exists() {
let bytes = tokio::fs::read(&path)
.await
.with_context(|| format!("failed reading device identity at {:?}", path))?;
let mut identity: NativeDeviceIdentity =
serde_json::from_slice(&bytes).context("failed parsing device identity json")?;
let current_source = infer_existing_name_source(&identity, &system_info);
let (candidate_name, candidate_source) =
resolved_name_candidate(preferred_name, &system_info);
let mut changed = false;
if preferred_name
.map(str::trim)
.filter(|name| !name.is_empty())
.is_some()
{
if identity.device_name != candidate_name {
identity.device_name = candidate_name.clone();
changed = true;
}
if identity.name_source.as_ref() != Some(&NativeDeviceNameSource::UserProvided) {
identity.name_source = Some(NativeDeviceNameSource::UserProvided);
changed = true;
}
} else if should_update_stored_name(¤t_source)
&& identity.device_name != candidate_name
{
identity.device_name = candidate_name.clone();
identity.name_source = Some(candidate_source);
changed = true;
} else if identity.name_source.as_ref() != Some(¤t_source) {
identity.name_source = Some(current_source);
changed = true;
}
if identity.system_info.as_ref() != Some(&system_info) {
identity.system_info = Some(system_info);
changed = true;
}
if changed {
identity.updated_at_ms = now_ms();
persist(base_dir, &identity).await?;
}
return Ok(identity);
}
let timestamp = now_ms();
let (device_name, name_source) = resolved_name_candidate(preferred_name, &system_info);
let identity = NativeDeviceIdentity {
device_id: uuid::Uuid::new_v4().to_string(),
device_name,
created_at_ms: timestamp,
updated_at_ms: timestamp,
name_source: Some(name_source),
system_info: Some(system_info),
};
persist(base_dir, &identity).await?;
Ok(identity)
}
pub async fn persist(base_dir: &Path, identity: &NativeDeviceIdentity) -> anyhow::Result<()> {
if !base_dir.exists() {
tokio::fs::create_dir_all(base_dir)
.await
.with_context(|| format!("failed to create device base dir at {:?}", base_dir))?;
}
let path = device_file_path(base_dir);
let payload =
serde_json::to_vec_pretty(identity).context("failed serializing device identity")?;
tokio::fs::write(&path, payload)
.await
.with_context(|| format!("failed writing device identity at {:?}", path))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn system_info_with_name(name: &str) -> NativeSystemDeviceInfo {
NativeSystemDeviceInfo {
device_name: Some(name.to_string()),
platform_type: "desktop".to_string(),
..Default::default()
}
}
#[test]
fn infer_existing_name_source_preserves_custom_names() {
let identity = NativeDeviceIdentity {
device_id: "dev".to_string(),
device_name: "Bryan's MacBook Pro".to_string(),
created_at_ms: 0,
updated_at_ms: 0,
name_source: None,
system_info: None,
};
let source = infer_existing_name_source(&identity, &system_info_with_name("Office Mac"));
assert_eq!(source, NativeDeviceNameSource::UserProvided);
}
#[test]
fn infer_existing_name_source_detects_fallback_names() {
let identity = NativeDeviceIdentity {
device_id: "dev".to_string(),
device_name: DESKTOP_FALLBACK_DEVICE_NAME.to_string(),
created_at_ms: 0,
updated_at_ms: 0,
name_source: None,
system_info: None,
};
let source = infer_existing_name_source(&identity, &system_info_with_name("Office Mac"));
assert_eq!(source, NativeDeviceNameSource::Fallback);
}
#[test]
fn resolved_name_candidate_prefers_user_and_system_over_fallback() {
let system_info = system_info_with_name("Office Mac");
let (preferred_name, preferred_source) =
resolved_name_candidate(Some("Custom Name"), &system_info);
assert_eq!(preferred_name, "Custom Name");
assert_eq!(preferred_source, NativeDeviceNameSource::UserProvided);
let (system_name, system_source) = resolved_name_candidate(None, &system_info);
assert_eq!(system_name, "Office Mac");
assert_eq!(system_source, NativeDeviceNameSource::SystemDetected);
}
}