refactor: don't bundle node backend

This commit is contained in:
zhom
2026-01-08 22:25:10 +04:00
parent 0bce20b4ee
commit fdd921c6bb
52 changed files with 7467 additions and 2051 deletions
+8 -11
View File
@@ -98,7 +98,7 @@ impl BrowserRunner {
);
}
// Handle camoufox profiles using nodecar launcher
// Handle Camoufox profiles using CamoufoxManager
if profile.browser == "camoufox" {
// Get or create camoufox config
let mut camoufox_config = profile.camoufox_config.clone().unwrap_or_else(|| {
@@ -205,14 +205,11 @@ impl BrowserRunner {
);
}
// Use the nodecar camoufox launcher
log::info!(
"Launching Camoufox via nodecar for profile: {}",
profile.name
);
// Launch Camoufox browser
log::info!("Launching Camoufox for profile: {}", profile.name);
let camoufox_result = self
.camoufox_manager
.launch_camoufox_profile_nodecar(
.launch_camoufox_profile(
app_handle.clone(),
updated_profile.clone(),
camoufox_config,
@@ -220,7 +217,7 @@ impl BrowserRunner {
)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to launch camoufox via nodecar: {e}").into()
format!("Failed to launch Camoufox: {e}").into()
})?;
// For server-based Camoufox, we use the process_id
@@ -547,7 +544,7 @@ impl BrowserRunner {
url: &str,
_internal_proxy_settings: Option<&ProxySettings>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Handle camoufox profiles using nodecar launcher
// Handle Camoufox profiles using CamoufoxManager
if profile.browser == "camoufox" {
// Get the profile path based on the UUID
let profiles_dir = self.profile_manager.get_profiles_dir();
@@ -657,7 +654,7 @@ impl BrowserRunner {
return Err("Unsupported platform".into());
}
BrowserType::Camoufox => {
// Camoufox uses nodecar for launching, URL opening is handled differently
// Camoufox URL opening is handled differently
Err("URL opening in existing Camoufox instance is not supported".into())
}
BrowserType::Chromium | BrowserType::Brave => {
@@ -941,7 +938,7 @@ impl BrowserRunner {
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Handle camoufox profiles using nodecar launcher
// Handle Camoufox profiles using CamoufoxManager
if profile.browser == "camoufox" {
// Search by profile path to find the running Camoufox instance
let profiles_dir = self.profile_manager.get_profiles_dir();
+608
View File
@@ -0,0 +1,608 @@
//! Camoufox configuration builder.
//!
//! Converts fingerprints to Camoufox configuration format and builds launch options.
use rand::Rng;
use serde_yaml;
use std::collections::HashMap;
use std::path::Path;
use crate::camoufox::data;
use crate::camoufox::env_vars;
use crate::camoufox::fingerprint::types::*;
use crate::camoufox::fonts;
use crate::camoufox::geolocation;
use crate::camoufox::webgl;
/// Browserforge mapping from YAML.
type BrowserforgeMapping = HashMap<String, serde_yaml::Value>;
/// Load the browserforge mapping from embedded YAML.
fn load_browserforge_mapping() -> BrowserforgeMapping {
serde_yaml::from_str(data::BROWSERFORGE_YML).unwrap_or_default()
}
/// Convert a fingerprint to Camoufox configuration.
pub fn from_browserforge(
fingerprint: &Fingerprint,
ff_version: Option<u32>,
) -> HashMap<String, serde_json::Value> {
let mapping = load_browserforge_mapping();
let mut config = HashMap::new();
// Convert fingerprint to a JSON value for easier traversal
let fp_json = serde_json::to_value(fingerprint).unwrap_or_default();
// Apply mappings recursively
cast_to_properties(&mut config, &mapping, &fp_json, ff_version);
// Handle window.screenX and window.screenY
handle_screen_xy(&mut config, &fingerprint.screen);
config
}
/// Recursively cast fingerprint properties to Camoufox config format.
fn cast_to_properties(
config: &mut HashMap<String, serde_json::Value>,
mapping: &BrowserforgeMapping,
fingerprint: &serde_json::Value,
ff_version: Option<u32>,
) {
if let serde_json::Value::Object(fp_obj) = fingerprint {
for (key, mapping_value) in mapping {
let fp_value = fp_obj.get(key);
match mapping_value {
serde_yaml::Value::String(target_key) => {
if let Some(value) = fp_value {
let mut final_value = value.clone();
// Handle negative screen values
if target_key.starts_with("screen.") {
if let Some(num) = final_value.as_i64() {
if num < 0 {
final_value = serde_json::json!(0);
}
}
}
// Replace Firefox version in user agent strings
if let (Some(version), Some(s)) = (ff_version, final_value.as_str()) {
let replaced = replace_ff_version(s, version);
final_value = serde_json::json!(replaced);
}
config.insert(target_key.clone(), final_value);
}
}
serde_yaml::Value::Mapping(nested_mapping) => {
if let Some(nested_fp) = fp_value {
let nested: BrowserforgeMapping = nested_mapping
.iter()
.filter_map(|(k, v)| k.as_str().map(|ks| (ks.to_string(), v.clone())))
.collect();
cast_to_properties(config, &nested, nested_fp, ff_version);
}
}
_ => {}
}
}
}
}
/// Replace Firefox version in user agent and related strings.
fn replace_ff_version(s: &str, version: u32) -> String {
// Match patterns like "135.0" (Firefox version) and replace with new version
let re = regex_lite::Regex::new(r"(?<!\d)(1[0-9]{2})(\.0)(?!\d)").unwrap_or_else(|_| {
// Fallback - just do simple replacement
regex_lite::Regex::new(r"Firefox/\d+").unwrap()
});
re.replace_all(s, format!("{}.0", version).as_str())
.to_string()
}
/// Handle window.screenX and window.screenY generation.
fn handle_screen_xy(config: &mut HashMap<String, serde_json::Value>, screen: &ScreenFingerprint) {
if config.contains_key("window.screenY") {
return;
}
let screen_x = screen.screen_x;
if screen_x == 0 {
config.insert("window.screenX".to_string(), serde_json::json!(0));
config.insert("window.screenY".to_string(), serde_json::json!(0));
return;
}
if (-50..=50).contains(&screen_x) {
config.insert("window.screenY".to_string(), serde_json::json!(screen_x));
return;
}
let screen_y = screen.avail_height as i32 - screen.outer_height as i32;
let mut rng = rand::rng();
let y = if screen_y == 0 {
0
} else if screen_y > 0 {
rng.random_range(0..=screen_y)
} else {
rng.random_range(screen_y..=0)
};
config.insert("window.screenY".to_string(), serde_json::json!(y));
}
/// GeoIP option - can be an IP address string or auto-detect.
#[derive(Debug, Clone)]
pub enum GeoIPOption {
/// Auto-detect IP (fetch public IP, optionally through proxy)
Auto,
/// Use a specific IP address
IP(String),
}
/// Configuration builder for Camoufox launch.
#[derive(Debug, Clone)]
pub struct CamoufoxConfigBuilder {
fingerprint: Option<Fingerprint>,
operating_system: Option<String>,
screen_constraints: Option<ScreenConstraints>,
block_images: bool,
block_webrtc: bool,
block_webgl: bool,
custom_fonts: Option<Vec<String>>,
custom_fonts_only: bool,
firefox_prefs: HashMap<String, serde_json::Value>,
proxy: Option<ProxyConfig>,
headless: bool,
ff_version: Option<u32>,
extra_config: HashMap<String, serde_json::Value>,
geoip: Option<GeoIPOption>,
}
/// Proxy configuration.
#[derive(Debug, Clone)]
pub struct ProxyConfig {
pub server: String,
pub username: Option<String>,
pub password: Option<String>,
pub bypass: Option<String>,
}
impl ProxyConfig {
/// Parse a proxy URL string into ProxyConfig.
/// Supports formats like:
/// - "http://host:port"
/// - "http://user:pass@host:port"
/// - "socks5://user:pass@host:port"
pub fn from_url(url: &str) -> Result<Self, ConfigError> {
let parsed = url::Url::parse(url).map_err(|e| ConfigError::InvalidProxy(e.to_string()))?;
let host = parsed
.host_str()
.ok_or_else(|| ConfigError::InvalidProxy("Missing host".to_string()))?;
let port = parsed.port().unwrap_or(8080);
let scheme = parsed.scheme();
let server = format!("{scheme}://{host}:{port}");
let username = if !parsed.username().is_empty() {
Some(parsed.username().to_string())
} else {
None
};
let password = parsed.password().map(String::from);
Ok(Self {
server,
username,
password,
bypass: None,
})
}
}
impl Default for CamoufoxConfigBuilder {
fn default() -> Self {
Self::new()
}
}
impl CamoufoxConfigBuilder {
pub fn new() -> Self {
Self {
fingerprint: None,
operating_system: None,
screen_constraints: None,
block_images: false,
block_webrtc: false,
block_webgl: false,
custom_fonts: None,
custom_fonts_only: false,
firefox_prefs: HashMap::new(),
proxy: None,
headless: false,
ff_version: None,
extra_config: HashMap::new(),
geoip: None,
}
}
pub fn fingerprint(mut self, fp: Fingerprint) -> Self {
self.fingerprint = Some(fp);
self
}
pub fn operating_system(mut self, os: &str) -> Self {
self.operating_system = Some(os.to_string());
self
}
pub fn screen_constraints(mut self, constraints: ScreenConstraints) -> Self {
self.screen_constraints = Some(constraints);
self
}
pub fn block_images(mut self, block: bool) -> Self {
self.block_images = block;
self
}
pub fn block_webrtc(mut self, block: bool) -> Self {
self.block_webrtc = block;
self
}
pub fn block_webgl(mut self, block: bool) -> Self {
self.block_webgl = block;
self
}
pub fn custom_fonts(mut self, fonts: Vec<String>) -> Self {
self.custom_fonts = Some(fonts);
self
}
pub fn custom_fonts_only(mut self, only: bool) -> Self {
self.custom_fonts_only = only;
self
}
pub fn firefox_pref<V: Into<serde_json::Value>>(mut self, key: &str, value: V) -> Self {
self.firefox_prefs.insert(key.to_string(), value.into());
self
}
pub fn proxy(mut self, proxy: ProxyConfig) -> Self {
self.proxy = Some(proxy);
self
}
pub fn headless(mut self, headless: bool) -> Self {
self.headless = headless;
self
}
pub fn ff_version(mut self, version: u32) -> Self {
self.ff_version = Some(version);
self
}
pub fn extra_config<V: Into<serde_json::Value>>(mut self, key: &str, value: V) -> Self {
self.extra_config.insert(key.to_string(), value.into());
self
}
/// Set GeoIP option for geolocation-based fingerprinting.
/// Use `GeoIPOption::Auto` to auto-detect public IP (optionally through proxy).
/// Use `GeoIPOption::IP(ip_string)` to use a specific IP address.
pub fn geoip(mut self, option: GeoIPOption) -> Self {
self.geoip = Some(option);
self
}
/// Build the complete Camoufox launch configuration.
pub fn build(self) -> Result<CamoufoxLaunchConfig, ConfigError> {
// Generate or use provided fingerprint
let fingerprint = if let Some(fp) = self.fingerprint {
fp
} else {
let generator = crate::camoufox::fingerprint::FingerprintGenerator::new()?;
let options = FingerprintOptions {
operating_system: self.operating_system.clone(),
browsers: Some(vec!["firefox".to_string()]),
devices: Some(vec!["desktop".to_string()]),
screen: self.screen_constraints,
..Default::default()
};
generator.get_fingerprint(&options)?.fingerprint
};
// Determine target OS from user agent
let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent);
// Convert fingerprint to config
let mut config = from_browserforge(&fingerprint, self.ff_version);
// Add random window history length
let mut rng = rand::rng();
config.insert(
"window.history.length".to_string(),
serde_json::json!(rng.random_range(1..=5)),
);
// Add fonts
if !self.custom_fonts_only {
let system_fonts = fonts::get_fonts_for_os(target_os);
let fonts = if let Some(custom) = &self.custom_fonts {
let mut all_fonts = system_fonts;
for font in custom {
if !all_fonts.contains(font) {
all_fonts.push(font.clone());
}
}
all_fonts
} else {
system_fonts
};
config.insert("fonts".to_string(), serde_json::json!(fonts));
} else if let Some(custom) = &self.custom_fonts {
config.insert("fonts".to_string(), serde_json::json!(custom));
}
// Add font spacing seed
config.insert(
"fonts:spacing_seed".to_string(),
serde_json::json!(rng.random_range(0..1_073_741_824u32)),
);
// Build Firefox preferences
let mut firefox_prefs = self.firefox_prefs;
if self.block_images {
firefox_prefs.insert(
"permissions.default.image".to_string(),
serde_json::json!(2),
);
}
if self.block_webrtc {
firefox_prefs.insert(
"media.peerconnection.enabled".to_string(),
serde_json::json!(false),
);
}
if self.block_webgl {
firefox_prefs.insert("webgl.disabled".to_string(), serde_json::json!(true));
} else {
// Sample and add WebGL configuration
match webgl::sample_webgl(target_os, None, None) {
Ok(webgl_data) => {
for (key, value) in webgl_data.config {
config.insert(key, value);
}
firefox_prefs.insert("webgl.force-enabled".to_string(), serde_json::json!(true));
}
Err(e) => {
log::warn!("Failed to sample WebGL config: {}", e);
}
}
}
// Canvas anti-fingerprinting
config.insert(
"canvas:aaOffset".to_string(),
serde_json::json!(rng.random_range(-50..=50)),
);
config.insert("canvas:aaCapOffset".to_string(), serde_json::json!(true));
// Add extra config (user-provided)
for (key, value) in self.extra_config {
config.insert(key, value);
}
// Hardcoded Camoufox settings (cannot be overridden)
// Disable theming to prevent fingerprinting via browser theme
config.insert("disableTheming".to_string(), serde_json::json!(true));
// Hide cursor in headless mode
config.insert("showcursor".to_string(), serde_json::json!(false));
Ok(CamoufoxLaunchConfig {
fingerprint_config: config,
firefox_prefs,
proxy: self.proxy,
headless: self.headless,
target_os: target_os.to_string(),
})
}
/// Build the complete Camoufox launch configuration with async geolocation support.
/// This method should be used when geoip option is set to Auto.
pub async fn build_async(self) -> Result<CamoufoxLaunchConfig, ConfigError> {
// Get proxy URL for IP detection if set
let proxy_url = self.proxy.as_ref().map(|p| p.server.clone());
let geoip_option = self.geoip.clone();
let block_webrtc = self.block_webrtc;
// Build base config first
let mut launch_config = self.build()?;
// Handle geolocation if geoip option is set
if let Some(geoip) = geoip_option {
let ip = match geoip {
GeoIPOption::Auto => {
// Fetch public IP, optionally through proxy
geolocation::fetch_public_ip(proxy_url.as_deref()).await?
}
GeoIPOption::IP(ip_str) => {
if !geolocation::validate_ip(&ip_str) {
return Err(ConfigError::Geolocation(
geolocation::GeolocationError::InvalidIP(ip_str),
));
}
ip_str
}
};
// Get geolocation from IP
match geolocation::get_geolocation(&ip) {
Ok(geo) => {
// Add geolocation config
for (key, value) in geo.as_config() {
launch_config.fingerprint_config.insert(key, value);
}
// Add WebRTC IP spoofing if not blocked
if !block_webrtc {
if geolocation::is_ipv4(&ip) {
launch_config
.fingerprint_config
.insert("webrtc:ipv4".to_string(), serde_json::json!(ip));
} else if geolocation::is_ipv6(&ip) {
launch_config
.fingerprint_config
.insert("webrtc:ipv6".to_string(), serde_json::json!(ip));
}
}
log::info!(
"Applied geolocation from IP {}: {} ({})",
ip,
geo.locale.as_string(),
geo.timezone
);
}
Err(e) => {
log::warn!("Failed to get geolocation for IP {}: {}", ip, e);
// Continue without geolocation rather than failing
}
}
}
Ok(launch_config)
}
}
/// Complete Camoufox launch configuration.
#[derive(Debug, Clone)]
pub struct CamoufoxLaunchConfig {
pub fingerprint_config: HashMap<String, serde_json::Value>,
pub firefox_prefs: HashMap<String, serde_json::Value>,
pub proxy: Option<ProxyConfig>,
pub headless: bool,
pub target_os: String,
}
impl CamoufoxLaunchConfig {
/// Get environment variables for launching Camoufox.
pub fn get_env_vars(&self) -> Result<HashMap<String, String>, serde_json::Error> {
env_vars::config_to_env_vars(&self.fingerprint_config)
}
/// Get the config as JSON string.
pub fn config_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&self.fingerprint_config)
}
}
/// Error type for configuration operations.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Fingerprint generation error: {0}")]
Fingerprint(#[from] crate::camoufox::fingerprint::FingerprintError),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("WebGL error: {0}")]
WebGL(#[from] webgl::WebGLError),
#[error("Invalid proxy configuration: {0}")]
InvalidProxy(String),
#[error("Geolocation error: {0}")]
Geolocation(#[from] crate::camoufox::geolocation::GeolocationError),
}
/// Get Firefox version from executable path.
pub fn get_firefox_version(executable_path: &Path) -> Option<u32> {
// Try to read version.json from the same directory
let version_path = executable_path.parent()?.join("version.json");
if let Ok(content) = std::fs::read_to_string(&version_path) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(version_str) = json.get("version").and_then(|v| v.as_str()) {
// Parse major version from "135.0" or similar
let major: u32 = version_str.split('.').next()?.parse().ok()?;
return Some(major);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_builder() {
let config = CamoufoxConfigBuilder::new()
.operating_system("windows")
.block_images(true)
.build();
assert!(config.is_ok());
let config = config.unwrap();
assert!(config
.firefox_prefs
.contains_key("permissions.default.image"));
}
#[test]
fn test_replace_ff_version() {
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0";
let replaced = replace_ff_version(ua, 140);
assert!(replaced.contains("140.0"));
}
#[test]
fn test_from_browserforge() {
let fingerprint = Fingerprint {
screen: ScreenFingerprint {
width: 1920,
height: 1080,
avail_width: 1920,
avail_height: 1040,
color_depth: 24,
pixel_depth: 24,
inner_width: 1903,
inner_height: 969,
outer_width: 1920,
outer_height: 1040,
..Default::default()
},
navigator: NavigatorFingerprint {
user_agent: "Mozilla/5.0 Firefox/135.0".to_string(),
platform: "Win32".to_string(),
language: "en-US".to_string(),
languages: vec!["en-US".to_string()],
hardware_concurrency: 8,
..Default::default()
},
..Default::default()
};
let config = from_browserforge(&fingerprint, Some(140));
assert!(config.contains_key("navigator.userAgent"));
assert!(config.contains_key("screen.width"));
}
}
@@ -0,0 +1,120 @@
[
"chrome/143.0.0.0|2",
"safari/18.3.1|2",
"chrome/101.0.4951.54|2",
"chrome/139.0.0.0|2",
"safari/16.6|2",
"safari/26.2|2",
"safari/18.6|2",
"safari/26.1|2",
"chrome/142.0.0.0|2",
"chrome/141.0.0.0|2",
"safari/18.7.3|2",
"edge/143.0.0.0|2",
"safari/18.4|2",
"safari/17.3.1|2",
"chrome/135.0.0.0|2",
"safari/18.5|2",
"safari/18.7.2|2",
"chrome/143.0.0.0|1",
"chrome/128.0.0.0|2",
"chrome/131.0.0.0|2",
"safari/26.3|2",
"safari/26.0.1|2",
"chrome/114.0.0.0|2",
"safari/18.1|2",
"firefox/147.0|2",
"safari/17.5|2",
"chrome/140.0.0.0|2",
"safari/16.6.1|2",
"firefox/146.0|2",
"chrome/124.0.0.0|1",
"chrome/34.0.1847.114|2",
"chrome/130.0.0.0|2",
"safari/15.6.7|2",
"chrome/144.0.0.0|2",
"safari/18.3|2",
"safari/16.4|2",
"chrome/141.0.7390.122|1",
"firefox/140.0|2",
"chrome/138.0.0.0|2",
"firefox/135.0|2",
"safari/17.6|2",
"chrome/132.0.0.0|2",
"chrome/109.0.0.0|2",
"chrome/92.0.4515.131|2",
"chrome/136.0.0.0|2",
"edge/142.0.0.0|2",
"chrome/125.0.0.0|2",
"safari/17.8|2",
"edge/143.0.0.0|1",
"chrome/123.0.0.0|2",
"chrome/137.0.0.0|2",
"chrome/129.0.0.0|2",
"chrome/126.0.0.0|2",
"safari/26.0|2",
"chrome/133.0.0.0|2",
"chrome/119.0.0.0|2",
"chrome/145.0.0.0|2",
"firefox/145.0|2",
"safari/17.3|2",
"safari/18.2|2",
"safari/16.5.2|2",
"safari/17.4|2",
"chrome/120.0.0.0|2",
"chrome/116.0.0.0|2",
"firefox/141.0|2",
"safari/17.4.1|2",
"chrome/134.0.0.0|2",
"safari/15.4|2",
"safari/18.1.1|2",
"edge/144.0.0.0|2",
"firefox/144.0|2",
"safari/16.3|2",
"safari/13.0.3|2",
"chrome/131.0.6778.33|2",
"edge/145.0.0.0|2",
"edge/139.0.0.0|2",
"safari/17.1|2",
"chrome/133.0.0.0|1",
"chrome/121.0.0.0|2",
"chrome/124.0.0.0|2",
"chrome/127.0.0.0|2",
"chrome/122.0.6261.95|2",
"chrome/91.0.4450.0|2",
"edge/134.0.0.0|2",
"chrome/134.0.6998.179|2",
"chrome/122.0.0.0|2",
"firefox/128.0|2",
"chrome/142.0.0.0|1",
"safari/18.7|2",
"safari/17.8.1|2",
"firefox/115.0|2",
"safari/17.2|2",
"chrome/117.0.0.0|2",
"safari/18.0.1|2",
"chrome/139.0.7258.5|2",
"edge/140.0.0.0|2",
"safari/16.5|2",
"safari/18.6.2|2",
"firefox/136.0|2",
"safari/17.2.1|2",
"safari/18.0|2",
"safari/15.6.1|2",
"safari/26.2|1",
"safari/17.1.2|2",
"safari/17.7|2",
"safari/16.2|2",
"edge/122.0.0.0|2",
"chrome/139.0.0.0|1",
"safari/17.0|2",
"firefox/139.0|2",
"chrome/101.0.9316.173|2",
"chrome/101.0.4951.64|2",
"chrome/141.0.0.0|1",
"safari/15.5|2",
"safari/18.6|1",
"chrome/112.0.0.0|2",
"edge/135.0.0.0|2",
"chrome/140.0.0.0|1"
]
@@ -0,0 +1,68 @@
# Mappings of Browserforge fingerprints to Camoufox config properties.
navigator:
# Note: Browserforge tends to have outdated UAs.
# The version will be replaced in Camoufox.
userAgent: navigator.userAgent
# userAgentData not in Firefox
doNotTrack: navigator.doNotTrack
appCodeName: navigator.appCodeName
appName: navigator.appName
appVersion: navigator.appVersion
oscpu: navigator.oscpu
# webdriver is always True
# Locale is now implemented separately:
# language: navigator.language
# languages: navigator.languages
platform: navigator.platform
# deviceMemory not in Firefox
hardwareConcurrency: navigator.hardwareConcurrency
product: navigator.product
# Never override productSub #105
# productSub: navigator.productSub
# vendor is not necessary
# vendorSub is not necessary
maxTouchPoints: navigator.maxTouchPoints
extraProperties:
# Note: Changing pdfViewerEnabled is not recommended. This will be kept to True.
globalPrivacyControl: navigator.globalPrivacyControl
screen:
# hasHDR is not implemented in Camoufox
availLeft: screen.availLeft
availTop: screen.availTop
availWidth: screen.availWidth
availHeight: screen.availHeight
height: screen.height
width: screen.width
colorDepth: screen.colorDepth
pixelDepth: screen.pixelDepth
# devicePixelRatio is not recommended. Any value other than 1.0 is suspicious.
pageXOffset: screen.pageXOffset
pageYOffset: screen.pageYOffset
outerHeight: window.outerHeight
outerWidth: window.outerWidth
innerHeight: window.innerHeight
innerWidth: window.innerWidth
screenX: window.screenX
screenY: window.screenY
# Tends to generate out of bounds (network inconsistencies):
# clientWidth: document.body.clientWidth
# clientHeight: document.body.clientHeight
# videoCard:
# renderer: webgl:renderer
# vendor: webgl:vendor
headers:
# headers.User-Agent is redundant with navigator.userAgent
# headers.Accept-Language is redundant with locale:*
Accept-Encoding: headers.Accept-Encoding
battery:
charging: battery:charging
chargingTime: battery:chargingTime
dischargingTime: battery:dischargingTime
# Unsupported: videoCodecs, audioCodecs, pluginsData, multimediaDevices
# Fonts are listed through the launcher.
+822
View File
@@ -0,0 +1,822 @@
{
"win": [
"Arial",
"Arial Black",
"Bahnschrift",
"Calibri",
"Calibri Light",
"Cambria",
"Cambria Math",
"Candara",
"Candara Light",
"Comic Sans MS",
"Consolas",
"Constantia",
"Corbel",
"Corbel Light",
"Courier New",
"Ebrima",
"Franklin Gothic Medium",
"Gabriola",
"Gadugi",
"Georgia",
"HoloLens MDL2 Assets",
"Impact",
"Ink Free",
"Javanese Text",
"Leelawadee UI",
"Leelawadee UI Semilight",
"Lucida Console",
"Lucida Sans Unicode",
"MS Gothic",
"MS PGothic",
"MS UI Gothic",
"MV Boli",
"Malgun Gothic",
"Malgun Gothic Semilight",
"Marlett",
"Microsoft Himalaya",
"Microsoft JhengHei",
"Microsoft JhengHei Light",
"Microsoft JhengHei UI",
"Microsoft JhengHei UI Light",
"Microsoft New Tai Lue",
"Microsoft PhagsPa",
"Microsoft Sans Serif",
"Microsoft Tai Le",
"Microsoft YaHei",
"Microsoft YaHei Light",
"Microsoft YaHei UI",
"Microsoft YaHei UI Light",
"Microsoft Yi Baiti",
"MingLiU-ExtB",
"MingLiU_HKSCS-ExtB",
"Mongolian Baiti",
"Myanmar Text",
"NSimSun",
"Nirmala UI",
"Nirmala UI Semilight",
"PMingLiU-ExtB",
"Palatino Linotype",
"Segoe Fluent Icons",
"Segoe MDL2 Assets",
"Segoe Print",
"Segoe Script",
"Segoe UI",
"Segoe UI Black",
"Segoe UI Emoji",
"Segoe UI Historic",
"Segoe UI Light",
"Segoe UI Semibold",
"Segoe UI Semilight",
"Segoe UI Symbol",
"Segoe UI Variable",
"SimSun",
"SimSun-ExtB",
"Sitka",
"Sitka Text",
"Sylfaen",
"Symbol",
"Tahoma",
"Times New Roman",
"Trebuchet MS",
"Twemoji Mozilla",
"Verdana",
"Webdings",
"Wingdings",
"Yu Gothic",
"Yu Gothic Light",
"Yu Gothic Medium",
"Yu Gothic UI",
"Yu Gothic UI Light",
"Yu Gothic UI Semibold",
"Yu Gothic UI Semilight",
"\u5b8b\u4f53",
"\u5fae\u8edf\u6b63\u9ed1\u9ad4",
"\u5fae\u8edf\u6b63\u9ed1\u9ad4 Light",
"\u5fae\u8f6f\u96c5\u9ed1",
"\u5fae\u8f6f\u96c5\u9ed1 Light",
"\u65b0\u5b8b\u4f53",
"\u65b0\u7d30\u660e\u9ad4-ExtB",
"\u6e38\u30b4\u30b7\u30c3\u30af",
"\u6e38\u30b4\u30b7\u30c3\u30af Light",
"\u6e38\u30b4\u30b7\u30c3\u30af Medium",
"\u7d30\u660e\u9ad4-ExtB",
"\u7d30\u660e\u9ad4_HKSCS-ExtB",
"\ub9d1\uc740 \uace0\ub515",
"\ub9d1\uc740 \uace0\ub515 Semilight",
"\uff2d\uff33 \u30b4\u30b7\u30c3\u30af",
"\uff2d\uff33 \uff30\u30b4\u30b7\u30c3\u30af"
],
"mac": [
".Al Bayan PUA",
".Al Nile PUA",
".Al Tarikh PUA",
".Apple Color Emoji UI",
".Apple SD Gothic NeoI",
".Aqua Kana",
".Aqua Kana Bold",
".Aqua \u304b\u306a",
".Aqua \u304b\u306a \u30dc\u30fc\u30eb\u30c9",
".Arial Hebrew Desk Interface",
".Baghdad PUA",
".Beirut PUA",
".Damascus PUA",
".DecoType Naskh PUA",
".Diwan Kufi PUA",
".Farah PUA",
".Geeza Pro Interface",
".Geeza Pro PUA",
".Helvetica LT MM",
".Hiragino Kaku Gothic Interface",
".Hiragino Sans GB Interface",
".Keyboard",
".KufiStandardGK PUA",
".LastResort",
".Lucida Grande UI",
".Muna PUA",
".Nadeem PUA",
".New York",
".Noto Nastaliq Urdu UI",
".PingFang HK",
".PingFang SC",
".PingFang TC",
".SF Arabic",
".SF Arabic Rounded",
".SF Compact",
".SF Compact Rounded",
".SF NS",
".SF NS Mono",
".SF NS Rounded",
".Sana PUA",
".Savoye LET CC.",
".ThonburiUI",
".ThonburiUIWatch",
".\u82f9\u65b9-\u6e2f",
".\u82f9\u65b9-\u7b80",
".\u82f9\u65b9-\u7e41",
".\u860b\u65b9-\u6e2f",
".\u860b\u65b9-\u7c21",
".\u860b\u65b9-\u7e41",
"Academy Engraved LET",
"Al Bayan",
"Al Nile",
"Al Tarikh",
"American Typewriter",
"Andale Mono",
"Apple Braille",
"Apple Chancery",
"Apple Color Emoji",
"Apple SD Gothic Neo",
"Apple SD \uc0b0\ub3cc\uace0\ub515 Neo",
"Apple Symbols",
"AppleGothic",
"AppleMyungjo",
"Arial",
"Arial Black",
"Arial Hebrew",
"Arial Hebrew Scholar",
"Arial Narrow",
"Arial Rounded MT Bold",
"Arial Unicode MS",
"Athelas",
"Avenir",
"Avenir Black",
"Avenir Black Oblique",
"Avenir Book",
"Avenir Heavy",
"Avenir Light",
"Avenir Medium",
"Avenir Next",
"Avenir Next Condensed",
"Avenir Next Condensed Demi Bold",
"Avenir Next Condensed Heavy",
"Avenir Next Condensed Medium",
"Avenir Next Condensed Ultra Light",
"Avenir Next Demi Bold",
"Avenir Next Heavy",
"Avenir Next Medium",
"Avenir Next Ultra Light",
"Ayuthaya",
"Baghdad",
"Bangla MN",
"Bangla Sangam MN",
"Baskerville",
"Beirut",
"Big Caslon",
"Bodoni 72",
"Bodoni 72 Oldstyle",
"Bodoni 72 Smallcaps",
"Bodoni Ornaments",
"Bradley Hand",
"Brush Script MT",
"Chalkboard",
"Chalkboard SE",
"Chalkduster",
"Charter",
"Charter Black",
"Cochin",
"Comic Sans MS",
"Copperplate",
"Corsiva Hebrew",
"Courier",
"Courier New",
"Czcionka systemowa",
"DIN Alternate",
"DIN Condensed",
"Damascus",
"DecoType Naskh",
"Devanagari MT",
"Devanagari Sangam MN",
"Didot",
"Diwan Kufi",
"Diwan Thuluth",
"Euphemia UCAS",
"Farah",
"Farisi",
"Font Sistem",
"Font de sistem",
"Font di sistema",
"Font sustava",
"Fonte do Sistema",
"Futura",
"GB18030 Bitmap",
"Galvji",
"Geeza Pro",
"Geneva",
"Georgia",
"Gill Sans",
"Grantha Sangam MN",
"Gujarati MT",
"Gujarati Sangam MN",
"Gurmukhi MN",
"Gurmukhi MT",
"Gurmukhi Sangam MN",
"Heiti SC",
"Heiti TC",
"Heiti-\uac04\uccb4",
"Heiti-\ubc88\uccb4",
"Helvetica",
"Helvetica Neue",
"Herculanum",
"Hiragino Kaku Gothic Pro",
"Hiragino Kaku Gothic Pro W3",
"Hiragino Kaku Gothic Pro W6",
"Hiragino Kaku Gothic ProN",
"Hiragino Kaku Gothic ProN W3",
"Hiragino Kaku Gothic ProN W6",
"Hiragino Kaku Gothic Std",
"Hiragino Kaku Gothic Std W8",
"Hiragino Kaku Gothic StdN",
"Hiragino Kaku Gothic StdN W8",
"Hiragino Maru Gothic Pro",
"Hiragino Maru Gothic Pro W4",
"Hiragino Maru Gothic ProN",
"Hiragino Maru Gothic ProN W4",
"Hiragino Mincho Pro",
"Hiragino Mincho Pro W3",
"Hiragino Mincho Pro W6",
"Hiragino Mincho ProN",
"Hiragino Mincho ProN W3",
"Hiragino Mincho ProN W6",
"Hiragino Sans",
"Hiragino Sans GB",
"Hiragino Sans GB W3",
"Hiragino Sans GB W6",
"Hiragino Sans W0",
"Hiragino Sans W1",
"Hiragino Sans W2",
"Hiragino Sans W3",
"Hiragino Sans W4",
"Hiragino Sans W5",
"Hiragino Sans W6",
"Hiragino Sans W7",
"Hiragino Sans W8",
"Hiragino Sans W9",
"Hoefler Text",
"Hoefler Text Ornaments",
"ITF Devanagari",
"ITF Devanagari Marathi",
"Impact",
"InaiMathi",
"Iowan Old Style",
"Iowan Old Style Black",
"J\u00e4rjestelm\u00e4fontti",
"Kailasa",
"Kannada MN",
"Kannada Sangam MN",
"Kefa",
"Khmer MN",
"Khmer Sangam MN",
"Kohinoor Bangla",
"Kohinoor Devanagari",
"Kohinoor Gujarati",
"Kohinoor Telugu",
"Kokonor",
"Krungthep",
"KufiStandardGK",
"Lao MN",
"Lao Sangam MN",
"Lucida Grande",
"Luminari",
"Malayalam MN",
"Malayalam Sangam MN",
"Marion",
"Marker Felt",
"Menlo",
"Microsoft Sans Serif",
"Mishafi",
"Mishafi Gold",
"Monaco",
"Mshtakan",
"Mukta Mahee",
"MuktaMahee Bold",
"MuktaMahee ExtraBold",
"MuktaMahee ExtraLight",
"MuktaMahee Light",
"MuktaMahee Medium",
"MuktaMahee Regular",
"MuktaMahee SemiBold",
"Muna",
"Myanmar MN",
"Myanmar Sangam MN",
"Nadeem",
"New Peninim MT",
"Noteworthy",
"Noto Nastaliq Urdu",
"Noto Sans Adlam",
"Noto Sans Armenian",
"Noto Sans Armenian Blk",
"Noto Sans Armenian ExtBd",
"Noto Sans Armenian ExtLt",
"Noto Sans Armenian Light",
"Noto Sans Armenian Med",
"Noto Sans Armenian SemBd",
"Noto Sans Armenian Thin",
"Noto Sans Avestan",
"Noto Sans Bamum",
"Noto Sans Bassa Vah",
"Noto Sans Batak",
"Noto Sans Bhaiksuki",
"Noto Sans Brahmi",
"Noto Sans Buginese",
"Noto Sans Buhid",
"Noto Sans CanAborig",
"Noto Sans Canadian Aboriginal",
"Noto Sans Carian",
"Noto Sans CaucAlban",
"Noto Sans Caucasian Albanian",
"Noto Sans Chakma",
"Noto Sans Cham",
"Noto Sans Coptic",
"Noto Sans Cuneiform",
"Noto Sans Cypriot",
"Noto Sans Duployan",
"Noto Sans EgyptHiero",
"Noto Sans Egyptian Hieroglyphs",
"Noto Sans Elbasan",
"Noto Sans Glagolitic",
"Noto Sans Gothic",
"Noto Sans Gunjala Gondi",
"Noto Sans Hanifi Rohingya",
"Noto Sans HanifiRohg",
"Noto Sans Hanunoo",
"Noto Sans Hatran",
"Noto Sans ImpAramaic",
"Noto Sans Imperial Aramaic",
"Noto Sans InsPahlavi",
"Noto Sans InsParthi",
"Noto Sans Inscriptional Pahlavi",
"Noto Sans Inscriptional Parthian",
"Noto Sans Javanese",
"Noto Sans Kaithi",
"Noto Sans Kannada",
"Noto Sans Kannada Black",
"Noto Sans Kannada ExtraBold",
"Noto Sans Kannada ExtraLight",
"Noto Sans Kannada Light",
"Noto Sans Kannada Medium",
"Noto Sans Kannada SemiBold",
"Noto Sans Kannada Thin",
"Noto Sans Kayah Li",
"Noto Sans Kharoshthi",
"Noto Sans Khojki",
"Noto Sans Khudawadi",
"Noto Sans Lepcha",
"Noto Sans Limbu",
"Noto Sans Linear A",
"Noto Sans Linear B",
"Noto Sans Lisu",
"Noto Sans Lycian",
"Noto Sans Lydian",
"Noto Sans Mahajani",
"Noto Sans Mandaic",
"Noto Sans Manichaean",
"Noto Sans Marchen",
"Noto Sans Masaram Gondi",
"Noto Sans Meetei Mayek",
"Noto Sans Mende Kikakui",
"Noto Sans Meroitic",
"Noto Sans Miao",
"Noto Sans Modi",
"Noto Sans Mongolian",
"Noto Sans Mro",
"Noto Sans Multani",
"Noto Sans Myanmar",
"Noto Sans Myanmar Blk",
"Noto Sans Myanmar ExtBd",
"Noto Sans Myanmar ExtLt",
"Noto Sans Myanmar Light",
"Noto Sans Myanmar Med",
"Noto Sans Myanmar SemBd",
"Noto Sans Myanmar Thin",
"Noto Sans NKo",
"Noto Sans Nabataean",
"Noto Sans New Tai Lue",
"Noto Sans Newa",
"Noto Sans Ol Chiki",
"Noto Sans Old Hungarian",
"Noto Sans Old Italic",
"Noto Sans Old North Arabian",
"Noto Sans Old Permic",
"Noto Sans Old Persian",
"Noto Sans Old South Arabian",
"Noto Sans Old Turkic",
"Noto Sans OldHung",
"Noto Sans OldNorArab",
"Noto Sans OldSouArab",
"Noto Sans Oriya",
"Noto Sans Osage",
"Noto Sans Osmanya",
"Noto Sans Pahawh Hmong",
"Noto Sans Palmyrene",
"Noto Sans Pau Cin Hau",
"Noto Sans PhagsPa",
"Noto Sans Phoenician",
"Noto Sans PsaPahlavi",
"Noto Sans Psalter Pahlavi",
"Noto Sans Rejang",
"Noto Sans Samaritan",
"Noto Sans Saurashtra",
"Noto Sans Sharada",
"Noto Sans Siddham",
"Noto Sans Sora Sompeng",
"Noto Sans SoraSomp",
"Noto Sans Sundanese",
"Noto Sans Syloti Nagri",
"Noto Sans Syriac",
"Noto Sans Tagalog",
"Noto Sans Tagbanwa",
"Noto Sans Tai Le",
"Noto Sans Tai Tham",
"Noto Sans Tai Viet",
"Noto Sans Takri",
"Noto Sans Thaana",
"Noto Sans Tifinagh",
"Noto Sans Tirhuta",
"Noto Sans Ugaritic",
"Noto Sans Vai",
"Noto Sans Wancho",
"Noto Sans Warang Citi",
"Noto Sans Yi",
"Noto Sans Zawgyi",
"Noto Sans Zawgyi Blk",
"Noto Sans Zawgyi ExtBd",
"Noto Sans Zawgyi ExtLt",
"Noto Sans Zawgyi Light",
"Noto Sans Zawgyi Med",
"Noto Sans Zawgyi SemBd",
"Noto Sans Zawgyi Thin",
"Noto Serif Ahom",
"Noto Serif Balinese",
"Noto Serif Hmong Nyiakeng",
"Noto Serif Myanmar",
"Noto Serif Myanmar Blk",
"Noto Serif Myanmar ExtBd",
"Noto Serif Myanmar ExtLt",
"Noto Serif Myanmar Light",
"Noto Serif Myanmar Med",
"Noto Serif Myanmar SemBd",
"Noto Serif Myanmar Thin",
"Noto Serif Yezidi",
"Optima",
"Oriya MN",
"Oriya Sangam MN",
"PT Mono",
"PT Sans",
"PT Sans Caption",
"PT Sans Narrow",
"PT Serif",
"PT Serif Caption",
"Palatino",
"Papyrus",
"Party LET",
"Phosphate",
"Ph\u00f4ng ch\u1eef H\u1ec7 th\u1ed1ng",
"PingFang HK",
"PingFang SC",
"PingFang TC",
"Plantagenet Cherokee",
"Police syst\u00e8me",
"Raanana",
"Rendszerbet\u0171t\u00edpus",
"Rockwell",
"STIX Two Math",
"STIX Two Text",
"STIXGeneral",
"STIXIntegralsD",
"STIXIntegralsSm",
"STIXIntegralsUp",
"STIXIntegralsUpD",
"STIXIntegralsUpSm",
"STIXNonUnicode",
"STIXSizeFiveSym",
"STIXSizeFourSym",
"STIXSizeOneSym",
"STIXSizeThreeSym",
"STIXSizeTwoSym",
"STIXVariants",
"STSong",
"Sana",
"Sathu",
"Savoye LET",
"Seravek",
"Seravek ExtraLight",
"Seravek Light",
"Seravek Medium",
"Shree Devanagari 714",
"SignPainter",
"SignPainter-HouseScript",
"Silom",
"Sinhala MN",
"Sinhala Sangam MN",
"Sistem Fontu",
"Skia",
"Snell Roundhand",
"Songti SC",
"Songti TC",
"Sukhumvit Set",
"Superclarendon",
"Symbol",
"Systeemlettertype",
"System Font",
"Systemschrift",
"Systemskrift",
"Systemtypsnitt",
"Syst\u00e9mov\u00e9 p\u00edsmo",
"Tahoma",
"Tamil MN",
"Tamil Sangam MN",
"Telugu MN",
"Telugu Sangam MN",
"Thonburi",
"Times",
"Times New Roman",
"Tipo de letra del sistema",
"Tipo de letra do sistema",
"Tipus de lletra del sistema",
"Trattatello",
"Trebuchet MS",
"Verdana",
"Waseem",
"Webdings",
"Wingdings",
"Wingdings 2",
"Wingdings 3",
"Zapf Dingbats",
"Zapfino",
"\u0393\u03c1\u03b1\u03bc\u03bc\u03b1\u03c4\u03bf\u03c3\u03b5\u03b9\u03c1\u03ac \u03c3\u03c5\u03c3\u03c4\u03ae\u03bc\u03b1\u03c4\u03bf\u03c2",
"\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u0438\u0439 \u0448\u0440\u0438\u0444\u0442",
"\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u044b\u0439 \u0448\u0440\u0438\u0444\u0442",
"\u05d2\u05d5\u05e4\u05df \u05de\u05e2\u05e8\u05db\u05ea",
"\u0627\u0644\u0628\u064a\u0627\u0646",
"\u0627\u0644\u062a\u0627\u0631\u064a\u062e",
"\u0627\u0644\u0646\u064a\u0644",
"\u0628\u063a\u062f\u0627\u062f",
"\u0628\u064a\u0631\u0648\u062a",
"\u062c\u064a\u0632\u0629",
"\u062e\u0637 \u0627\u0644\u0646\u0638\u0627\u0645",
"\u062f\u0645\u0634\u0642",
"\u062f\u064a\u0648\u0627\u0646 \u062b\u0644\u062b",
"\u062f\u064a\u0648\u0627\u0646 \u0643\u0648\u0641\u064a",
"\u0635\u0646\u0639\u0627\u0621",
"\u0641\u0627\u0631\u0633\u064a",
"\u0641\u0631\u062d",
"\u0643\u0648\u0641\u064a",
"\u0645\u0646\u0649",
"\u0645\u0650\u0635\u062d\u0641\u064a",
"\u0645\u0650\u0635\u062d\u0641\u064a \u0630\u0647\u0628\u064a",
"\u0646\u062f\u064a\u0645",
"\u0646\u0633\u062e",
"\u0648\u0633\u064a\u0645",
"\u0906\u0908\u0970\u091f\u0940\u0970\u090f\u092b\u093c\u0970 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940",
"\u0906\u0908\u0970\u091f\u0940\u0970\u090f\u092b\u093c\u0970 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u092e\u0930\u093e\u0920\u0940",
"\u0915\u094b\u0939\u093f\u0928\u0942\u0930 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940",
"\u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u090f\u092e\u0970\u091f\u0940\u0970",
"\u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u0938\u0902\u0917\u092e \u090f\u092e\u0970\u090f\u0928\u0970",
"\u0936\u094d\u0930\u0940 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u096d\u0967\u096a",
"\u0e41\u0e1a\u0e1a\u0e2d\u0e31\u0e01\u0e29\u0e23\u0e23\u0e30\u0e1a\u0e1a",
"\u2e41\u7175\u6120\u82a9\u82c8",
"\u30b7\u30b9\u30c6\u30e0\u30d5\u30a9\u30f3\u30c8",
"\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 Pro",
"\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 Pro W4",
"\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 ProN",
"\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 ProN W4",
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d Pro",
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d Pro W3",
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d Pro W6",
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d ProN",
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d ProN W3",
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d ProN W6",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Pro",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Pro W3",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Pro W6",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 ProN",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 ProN W3",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 ProN W6",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Std",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Std W8",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 StdN",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 StdN W8",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 \u7c21\u4f53\u4e2d\u6587",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 \u7c21\u4f53\u4e2d\u6587 W3",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 \u7c21\u4f53\u4e2d\u6587 W6",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W0",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W1",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W2",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W3",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W4",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W5",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W6",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W7",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W8",
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W9",
"\u51ac\u9752\u9ed1\u4f53\u7b80\u4f53\u4e2d\u6587",
"\u51ac\u9752\u9ed1\u4f53\u7b80\u4f53\u4e2d\u6587 W3",
"\u51ac\u9752\u9ed1\u4f53\u7b80\u4f53\u4e2d\u6587 W6",
"\u51ac\u9752\u9ed1\u9ad4\u7c21\u9ad4\u4e2d\u6587",
"\u51ac\u9752\u9ed1\u9ad4\u7c21\u9ad4\u4e2d\u6587 W3",
"\u51ac\u9752\u9ed1\u9ad4\u7c21\u9ad4\u4e2d\u6587 W6",
"\u5b8b\u4f53-\u7b80",
"\u5b8b\u4f53-\u7e41",
"\u5b8b\u9ad4-\u7c21",
"\u5b8b\u9ad4-\u7e41",
"\u7cfb\u7d71\u5b57\u9ad4",
"\u7cfb\u7edf\u5b57\u4f53",
"\u82f9\u65b9-\u6e2f",
"\u82f9\u65b9-\u7b80",
"\u82f9\u65b9-\u7e41",
"\u8371\u8389\u834d\u836d\u8a70\u8353\u2050\u726f",
"\u8371\u8389\u834d\u836d\u8a70\u8353\u2053\u7464",
"\u8371\u8389\u834d\u836d\u8a70\u8353\u8356\u8362\u834e",
"\u8371\u8389\u834d\u836d\u8adb\u8353\u2050\u726f",
"\u8371\u8389\u834d\u836d\u96be\u92a9\u2050\u726f",
"\u860b\u65b9-\u6e2f",
"\u860b\u65b9-\u7c21",
"\u860b\u65b9-\u7e41",
"\u9ed1\u4f53-\u7b80",
"\u9ed1\u4f53-\u7e41",
"\u9ed1\u9ad4-\u7c21",
"\u9ed1\u9ad4-\u7e41",
"\u9ed2\u4f53-\u7c21",
"\u9ed2\u4f53-\u7e41",
"\uc2dc\uc2a4\ud15c \uc11c\uccb4"
],
"lin": [
"Arimo",
"Cousine",
"Noto Naskh Arabic",
"Noto Sans Adlam",
"Noto Sans Armenian",
"Noto Sans Balinese",
"Noto Sans Bamum",
"Noto Sans Bassa Vah",
"Noto Sans Batak",
"Noto Sans Bengali",
"Noto Sans Buginese",
"Noto Sans Buhid",
"Noto Sans Canadian Aboriginal",
"Noto Sans Chakma",
"Noto Sans Cham",
"Noto Sans Cherokee",
"Noto Sans Coptic",
"Noto Sans Deseret",
"Noto Sans Devanagari",
"Noto Sans Elbasan",
"Noto Sans Ethiopic",
"Noto Sans Georgian",
"Noto Sans Grantha",
"Noto Sans Gujarati",
"Noto Sans Gunjala Gondi",
"Noto Sans Gurmukhi",
"Noto Sans Hanifi Rohingya",
"Noto Sans Hanunoo",
"Noto Sans Hebrew",
"Noto Sans JP",
"Noto Sans Javanese",
"Noto Sans KR",
"Noto Sans Kannada",
"Noto Sans Kayah Li",
"Noto Sans Khmer",
"Noto Sans Khojki",
"Noto Sans Khudawadi",
"Noto Sans Lao",
"Noto Sans Lepcha",
"Noto Sans Limbu",
"Noto Sans Lisu",
"Noto Sans Mahajani",
"Noto Sans Malayalam",
"Noto Sans Mandaic",
"Noto Sans Masaram Gondi",
"Noto Sans Medefaidrin",
"Noto Sans Meetei Mayek",
"Noto Sans Mende Kikakui",
"Noto Sans Miao",
"Noto Sans Modi",
"Noto Sans Mongolian",
"Noto Sans Mro",
"Noto Sans Multani",
"Noto Sans Myanmar",
"Noto Sans NKo",
"Noto Sans New Tai Lue",
"Noto Sans Newa",
"Noto Sans Ol Chiki",
"Noto Sans Oriya",
"Noto Sans Osage",
"Noto Sans Osmanya",
"Noto Sans Pahawh Hmong",
"Noto Sans Pau Cin Hau",
"Noto Sans Rejang",
"Noto Sans Runic",
"Noto Sans SC",
"Noto Sans Samaritan",
"Noto Sans Saurashtra",
"Noto Sans Sharada",
"Noto Sans Shavian",
"Noto Sans Sinhala",
"Noto Sans Sora Sompeng",
"Noto Sans Soyombo",
"Noto Sans Sundanese",
"Noto Sans Syloti Nagri",
"Noto Sans Symbols",
"Noto Sans Symbols 2",
"Noto Sans Syriac",
"Noto Sans TC",
"Noto Sans Tagalog",
"Noto Sans Tagbanwa",
"Noto Sans Tai Le",
"Noto Sans Tai Tham",
"Noto Sans Tai Viet",
"Noto Sans Takri",
"Noto Sans Tamil",
"Noto Sans Telugu",
"Noto Sans Thaana",
"Noto Sans Thai",
"Noto Sans Tifinagh",
"Noto Sans Tifinagh APT",
"Noto Sans Tifinagh Adrar",
"Noto Sans Tifinagh Agraw Imazighen",
"Noto Sans Tifinagh Ahaggar",
"Noto Sans Tifinagh Air",
"Noto Sans Tifinagh Azawagh",
"Noto Sans Tifinagh Ghat",
"Noto Sans Tifinagh Hawad",
"Noto Sans Tifinagh Rhissa Ixa",
"Noto Sans Tifinagh SIL",
"Noto Sans Tifinagh Tawellemmet",
"Noto Sans Tirhuta",
"Noto Sans Vai",
"Noto Sans Wancho",
"Noto Sans Warang Citi",
"Noto Sans Yi",
"Noto Sans Zanabazar Square",
"Noto Serif Armenian",
"Noto Serif Balinese",
"Noto Serif Bengali",
"Noto Serif Devanagari",
"Noto Serif Dogra",
"Noto Serif Ethiopic",
"Noto Serif Georgian",
"Noto Serif Grantha",
"Noto Serif Gujarati",
"Noto Serif Gurmukhi",
"Noto Serif Hebrew",
"Noto Serif Kannada",
"Noto Serif Khmer",
"Noto Serif Khojki",
"Noto Serif Lao",
"Noto Serif Malayalam",
"Noto Serif Myanmar",
"Noto Serif NP Hmong",
"Noto Serif Sinhala",
"Noto Serif Tamil",
"Noto Serif Telugu",
"Noto Serif Thai",
"Noto Serif Tibetan",
"Noto Serif Yezidi",
"STIX Two Math",
"Tinos",
"Twemoji Mozilla"
]
}
@@ -0,0 +1,164 @@
{
"safari": [
"Referer",
"Origin",
"Content-Type",
"Accept",
"Upgrade-Insecure-Requests",
"User-Agent",
"Content-Length",
"Accept-Encoding",
"Accept-Language",
"Connection",
"Host",
"Cookie",
"Sec-Fetch-Dest",
"Sec-Fetch-Mode",
"Sec-Fetch-Site",
":method",
":scheme",
":authority",
":path",
"referer",
"origin",
"content-type",
"accept",
"user-agent",
"content-length",
"accept-encoding",
"accept-language",
"cookie",
"sec-fetch-dest",
"sec-fetch-mode",
"sec-fetch-site"
],
"chrome": [
"Host",
"Connection",
"Content-Length",
"Cache-Control",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"Origin",
"Content-Type",
"Upgrade-Insecure-Requests",
"User-Agent",
"Accept",
"Sec-Fetch-Site",
"Sec-Fetch-Mode",
"Sec-Fetch-User",
"Sec-Fetch-Dest",
"Referer",
"Accept-Encoding",
"Accept-Language",
"Cookie",
":method",
":authority",
":scheme",
":path",
"content-length",
"cache-control",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"origin",
"content-type",
"upgrade-insecure-requests",
"user-agent",
"accept",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-user",
"sec-fetch-dest",
"referer",
"accept-encoding",
"accept-language",
"cookie",
"priority"
],
"firefox": [
"Host",
"User-Agent",
"Accept",
"Accept-Language",
"Accept-Encoding",
"Content-Type",
"Content-Length",
"Origin",
"Connection",
"Referer",
"Cookie",
"Upgrade-Insecure-Requests",
"Sec-Fetch-Dest",
"Sec-Fetch-Mode",
"Sec-Fetch-Site",
"Sec-Fetch-User",
"Priority",
":method",
":path",
":authority",
":scheme",
"user-agent",
"accept",
"accept-language",
"accept-encoding",
"content-type",
"content-length",
"origin",
"referer",
"cookie",
"upgrade-insecure-requests",
"sec-fetch-dest",
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-user",
"priority",
"te"
],
"edge": [
"Host",
"Connection",
"Content-Length",
"Cache-Control",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"Origin",
"Content-Type",
"Upgrade-Insecure-Requests",
"User-Agent",
"Accept",
"Sec-Fetch-Site",
"Sec-Fetch-Mode",
"Sec-Fetch-User",
"Sec-Fetch-Dest",
"Referer",
"Accept-Encoding",
"Accept-Language",
"Cookie",
":method",
":authority",
":scheme",
":path",
"content-length",
"cache-control",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"origin",
"content-type",
"upgrade-insecure-requests",
"user-agent",
"accept",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-user",
"sec-fetch-dest",
"referer",
"accept-encoding",
"accept-language",
"cookie",
"priority"
]
}
+9
View File
@@ -0,0 +1,9 @@
pub const FINGERPRINT_NETWORK_ZIP: &[u8] = include_bytes!("fingerprint-network-definition.zip");
pub const INPUT_NETWORK_ZIP: &[u8] = include_bytes!("input-network-definition.zip");
pub const HEADER_NETWORK_ZIP: &[u8] = include_bytes!("header-network-definition.zip");
pub const BROWSER_HELPER_JSON: &str = include_str!("browser-helper-file.json");
pub const HEADERS_ORDER_JSON: &str = include_str!("headers-order.json");
pub const FONTS_JSON: &str = include_str!("fonts.json");
pub const BROWSERFORGE_YML: &str = include_str!("browserforge.yml");
pub const WEBGL_DATA_DB: &[u8] = include_bytes!("webgl_data.db");
pub const TERRITORY_INFO_XML: &str = include_str!("territoryInfo.xml");
File diff suppressed because it is too large Load Diff
Binary file not shown.
+142
View File
@@ -0,0 +1,142 @@
//! Environment variable handling for Camoufox configuration.
//!
//! Camoufox reads its configuration from environment variables named CAMOU_CONFIG_1, CAMOU_CONFIG_2, etc.
//! The configuration JSON is chunked to fit within environment variable size limits.
use std::collections::HashMap;
/// Maximum chunk size for environment variables on Windows.
const CHUNK_SIZE_WINDOWS: usize = 2047;
/// Maximum chunk size for environment variables on Unix systems.
const CHUNK_SIZE_UNIX: usize = 32767;
/// Get the chunk size for the current platform.
fn get_chunk_size() -> usize {
if cfg!(windows) {
CHUNK_SIZE_WINDOWS
} else {
CHUNK_SIZE_UNIX
}
}
/// Convert a Camoufox config map to environment variables.
///
/// The config is serialized to JSON and split into chunks that fit within
/// environment variable size limits. Each chunk is stored in a variable
/// named CAMOU_CONFIG_1, CAMOU_CONFIG_2, etc.
pub fn config_to_env_vars(
config: &HashMap<String, serde_json::Value>,
) -> Result<HashMap<String, String>, serde_json::Error> {
let config_json = serde_json::to_string(config)?;
Ok(chunk_config_string(&config_json))
}
/// Split a config string into chunks and create environment variable map.
pub fn chunk_config_string(config_str: &str) -> HashMap<String, String> {
let chunk_size = get_chunk_size();
let mut env_vars = HashMap::new();
for (i, chunk) in config_str.as_bytes().chunks(chunk_size).enumerate() {
let chunk_str = String::from_utf8_lossy(chunk).to_string();
let env_name = format!("CAMOU_CONFIG_{}", i + 1);
env_vars.insert(env_name, chunk_str);
}
env_vars
}
/// Determine the target OS from a user agent string.
pub fn determine_ua_os(user_agent: &str) -> &'static str {
let ua_lower = user_agent.to_lowercase();
if ua_lower.contains("mac os") || ua_lower.contains("macos") || ua_lower.contains("macintosh") {
"mac"
} else if ua_lower.contains("windows") {
"win"
} else {
"lin"
}
}
/// Get the fontconfig path environment variable for Linux.
pub fn get_fontconfig_env(target_os: &str, camoufox_path: &std::path::Path) -> Option<String> {
if cfg!(target_os = "linux") {
let fontconfig_dir = camoufox_path.join("fontconfig").join(target_os);
if fontconfig_dir.exists() {
return Some(fontconfig_dir.to_string_lossy().to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chunk_small_config() {
let config = r#"{"navigator.userAgent": "Mozilla/5.0"}"#;
let env_vars = chunk_config_string(config);
assert_eq!(env_vars.len(), 1);
assert!(env_vars.contains_key("CAMOU_CONFIG_1"));
assert_eq!(env_vars.get("CAMOU_CONFIG_1").unwrap(), config);
}
#[test]
fn test_chunk_large_config() {
// Create a config string larger than the chunk size
let chunk_size = get_chunk_size();
let large_value = "x".repeat(chunk_size * 2 + 100);
let config = format!(r#"{{"key": "{}"}}"#, large_value);
let env_vars = chunk_config_string(&config);
// Should have at least 2 chunks
assert!(env_vars.len() >= 2);
assert!(env_vars.contains_key("CAMOU_CONFIG_1"));
assert!(env_vars.contains_key("CAMOU_CONFIG_2"));
// Reconstruct and verify
let mut reconstructed = String::new();
let mut i = 1;
while let Some(chunk) = env_vars.get(&format!("CAMOU_CONFIG_{}", i)) {
reconstructed.push_str(chunk);
i += 1;
}
assert_eq!(reconstructed, config);
}
#[test]
fn test_determine_ua_os_windows() {
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0";
assert_eq!(determine_ua_os(ua), "win");
}
#[test]
fn test_determine_ua_os_macos() {
let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0";
assert_eq!(determine_ua_os(ua), "mac");
}
#[test]
fn test_determine_ua_os_linux() {
let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0";
assert_eq!(determine_ua_os(ua), "lin");
}
#[test]
fn test_config_to_env_vars() {
let mut config = HashMap::new();
config.insert(
"navigator.userAgent".to_string(),
serde_json::json!("Mozilla/5.0 Firefox/135.0"),
);
config.insert("screen.width".to_string(), serde_json::json!(1920));
let env_vars = config_to_env_vars(&config).unwrap();
assert!(!env_vars.is_empty());
assert!(env_vars.contains_key("CAMOU_CONFIG_1"));
}
}
@@ -0,0 +1,198 @@
//! Bayesian network for fingerprint generation.
//!
//! Loads pre-trained probability distributions from ZIP files and samples fingerprints.
use super::bayesian_node::{BayesianNode, NodeDefinition};
use serde::Deserialize;
use std::collections::HashMap;
use std::io::{Cursor, Read};
use zip::ZipArchive;
/// Network definition structure from the ZIP file.
#[derive(Debug, Deserialize)]
pub struct NetworkDefinition {
pub nodes: Vec<NodeDefinition>,
}
/// A Bayesian network for generating consistent fingerprints.
pub struct BayesianNetwork {
nodes_in_sampling_order: Vec<BayesianNode>,
nodes_by_name: HashMap<String, usize>,
}
impl BayesianNetwork {
/// Load a Bayesian network from embedded ZIP file bytes.
pub fn from_zip_bytes(zip_bytes: &[u8]) -> Result<Self, BayesianNetworkError> {
let cursor = Cursor::new(zip_bytes);
let mut archive = ZipArchive::new(cursor)?;
// Find and read the JSON file from the ZIP
let mut json_content = String::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
if file.name().ends_with(".json") {
file.read_to_string(&mut json_content)?;
break;
}
}
if json_content.is_empty() {
return Err(BayesianNetworkError::NoJsonInZip);
}
let definition: NetworkDefinition = serde_json::from_str(&json_content)?;
let mut nodes_in_sampling_order = Vec::with_capacity(definition.nodes.len());
let mut nodes_by_name = HashMap::with_capacity(definition.nodes.len());
for (i, node_def) in definition.nodes.into_iter().enumerate() {
nodes_by_name.insert(node_def.name.clone(), i);
nodes_in_sampling_order.push(BayesianNode::new(node_def));
}
Ok(Self {
nodes_in_sampling_order,
nodes_by_name,
})
}
/// Get a node by name.
pub fn get_node(&self, name: &str) -> Option<&BayesianNode> {
self
.nodes_by_name
.get(name)
.map(|&i| &self.nodes_in_sampling_order[i])
}
/// Get possible values for a node.
pub fn get_possible_values(&self, name: &str) -> Option<Vec<String>> {
self
.get_node(name)
.map(|node| node.possible_values().to_vec())
}
/// Generate a random sample from the network.
///
/// `input_values` contains already known node values that should not be overwritten.
pub fn generate_sample(&self, input_values: &HashMap<String, String>) -> HashMap<String, String> {
let mut sample = input_values.clone();
for node in &self.nodes_in_sampling_order {
if !sample.contains_key(node.name()) {
let value = node.sample(&sample);
sample.insert(node.name().to_string(), value);
}
}
sample
}
/// Generate a random sample consistent with the given value restrictions.
///
/// Uses backtracking to find a valid configuration.
/// Returns `None` if no consistent sample can be generated.
pub fn generate_consistent_sample_when_possible(
&self,
value_possibilities: &HashMap<String, Vec<String>>,
) -> Option<HashMap<String, String>> {
self.recursively_generate_consistent_sample(HashMap::new(), value_possibilities, 0)
}
fn recursively_generate_consistent_sample(
&self,
sample_so_far: HashMap<String, String>,
value_possibilities: &HashMap<String, Vec<String>>,
depth: usize,
) -> Option<HashMap<String, String>> {
if depth >= self.nodes_in_sampling_order.len() {
return Some(sample_so_far);
}
let node = &self.nodes_in_sampling_order[depth];
let mut banned_values: Vec<String> = Vec::new();
let mut sample_so_far = sample_so_far;
loop {
let sample_value = node.sample_according_to_restrictions(
&sample_so_far,
value_possibilities.get(node.name()).map(|v| v.as_slice()),
&banned_values,
);
let Some(value) = sample_value else {
break;
};
sample_so_far.insert(node.name().to_string(), value.clone());
if let Some(complete_sample) = self.recursively_generate_consistent_sample(
sample_so_far.clone(),
value_possibilities,
depth + 1,
) {
return Some(complete_sample);
}
banned_values.push(value);
}
None
}
}
/// Errors that can occur when working with Bayesian networks.
#[derive(Debug, thiserror::Error)]
pub enum BayesianNetworkError {
#[error("ZIP file error: {0}")]
Zip(#[from] zip::result::ZipError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parsing error: {0}")]
Json(#[from] serde_json::Error),
#[error("No JSON file found in ZIP archive")]
NoJsonInZip,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_input_network() {
let zip_bytes = include_bytes!("../data/input-network-definition.zip");
let network = BayesianNetwork::from_zip_bytes(zip_bytes);
assert!(
network.is_ok(),
"Failed to load input network: {:?}",
network.err()
);
}
#[test]
fn test_generate_sample_from_input_network() {
let zip_bytes = include_bytes!("../data/input-network-definition.zip");
let network = BayesianNetwork::from_zip_bytes(zip_bytes).unwrap();
let sample = network.generate_sample(&HashMap::new());
assert!(!sample.is_empty(), "Sample should not be empty");
}
#[test]
fn test_generate_consistent_sample() {
let zip_bytes = include_bytes!("../data/input-network-definition.zip");
let network = BayesianNetwork::from_zip_bytes(zip_bytes).unwrap();
let mut constraints = HashMap::new();
constraints.insert("*OPERATING_SYSTEM".to_string(), vec!["windows".to_string()]);
let sample = network.generate_consistent_sample_when_possible(&constraints);
assert!(sample.is_some(), "Should generate a consistent sample");
if let Some(s) = sample {
assert_eq!(s.get("*OPERATING_SYSTEM"), Some(&"windows".to_string()));
}
}
}
@@ -0,0 +1,231 @@
//! Bayesian network node implementation for fingerprint generation.
//!
//! Implements weighted random sampling from conditional probability distributions.
use rand::Rng;
use serde::Deserialize;
use std::collections::HashMap;
/// Node definition from the network JSON file.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeDefinition {
pub name: String,
pub parent_names: Vec<String>,
pub possible_values: Vec<String>,
pub conditional_probabilities: ConditionalProbabilities,
}
/// Conditional probability structure - can be nested or terminal.
#[derive(Debug, Clone, Deserialize)]
pub struct ConditionalProbabilities {
#[serde(default)]
pub deeper: Option<HashMap<String, ConditionalProbabilities>>,
#[serde(default)]
pub skip: Option<Box<ConditionalProbabilities>>,
#[serde(flatten)]
pub probabilities: HashMap<String, f64>,
}
impl ConditionalProbabilities {
/// Check if this is a terminal node (has actual probabilities, not deeper nesting)
pub fn is_terminal(&self) -> bool {
self.deeper.is_none()
}
}
/// A single node in the Bayesian network.
pub struct BayesianNode {
definition: NodeDefinition,
}
impl BayesianNode {
pub fn new(definition: NodeDefinition) -> Self {
Self { definition }
}
pub fn name(&self) -> &str {
&self.definition.name
}
pub fn parent_names(&self) -> &[String] {
&self.definition.parent_names
}
pub fn possible_values(&self) -> &[String] {
&self.definition.possible_values
}
/// Get the probability distribution given parent node values.
fn get_probabilities_given_known_values(
&self,
parent_values: &HashMap<String, String>,
) -> HashMap<String, f64> {
let mut probabilities = &self.definition.conditional_probabilities;
for parent_name in &self.definition.parent_names {
if let Some(deeper) = &probabilities.deeper {
if let Some(parent_value) = parent_values.get(parent_name) {
if let Some(next_level) = deeper.get(parent_value) {
probabilities = next_level;
continue;
}
}
// Use skip if parent value not found in deeper
if let Some(skip) = &probabilities.skip {
probabilities = skip;
}
}
}
probabilities.probabilities.clone()
}
/// Randomly sample from the given values using the given probabilities.
fn sample_random_value_from_possibilities(
possible_values: &[String],
total_probability: f64,
probabilities: &HashMap<String, f64>,
) -> String {
if possible_values.is_empty() {
return String::new();
}
let mut rng = rand::rng();
let anchor = rng.random::<f64>() * total_probability;
let mut cumulative = 0.0;
for value in possible_values {
if let Some(&prob) = probabilities.get(value) {
cumulative += prob;
if cumulative > anchor {
return value.clone();
}
}
}
possible_values.first().cloned().unwrap_or_default()
}
/// Sample a value from the conditional distribution given parent values.
pub fn sample(&self, parent_values: &HashMap<String, String>) -> String {
let probabilities = self.get_probabilities_given_known_values(parent_values);
let possible_values: Vec<String> = probabilities.keys().cloned().collect();
Self::sample_random_value_from_possibilities(&possible_values, 1.0, &probabilities)
}
/// Sample according to restrictions on possible values.
///
/// Returns `None` if no valid value can be sampled.
pub fn sample_according_to_restrictions(
&self,
parent_values: &HashMap<String, String>,
value_possibilities: Option<&[String]>,
banned_values: &[String],
) -> Option<String> {
let probabilities = self.get_probabilities_given_known_values(parent_values);
let values_in_distribution: Vec<String> = probabilities.keys().cloned().collect();
let possible_values = value_possibilities.unwrap_or(&values_in_distribution);
let mut valid_values = Vec::new();
let mut total_probability = 0.0;
for value in possible_values {
if !banned_values.contains(value) && values_in_distribution.contains(value) {
if let Some(&prob) = probabilities.get(value) {
valid_values.push(value.clone());
total_probability += prob;
}
}
}
if valid_values.is_empty() {
return None;
}
Some(Self::sample_random_value_from_possibilities(
&valid_values,
total_probability,
&probabilities,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_node() -> BayesianNode {
let mut probs = HashMap::new();
probs.insert("1920".to_string(), 0.5);
probs.insert("1366".to_string(), 0.3);
probs.insert("1536".to_string(), 0.2);
let definition = NodeDefinition {
name: "screen.width".to_string(),
parent_names: vec![],
possible_values: vec!["1920".to_string(), "1366".to_string(), "1536".to_string()],
conditional_probabilities: ConditionalProbabilities {
deeper: None,
skip: None,
probabilities: probs,
},
};
BayesianNode::new(definition)
}
#[test]
fn test_sample_returns_valid_value() {
let node = create_test_node();
let parent_values = HashMap::new();
for _ in 0..100 {
let value = node.sample(&parent_values);
assert!(
node.possible_values().contains(&value),
"Sampled value '{}' not in possible values",
value
);
}
}
#[test]
fn test_sample_with_restrictions() {
let node = create_test_node();
let parent_values = HashMap::new();
let allowed = vec!["1920".to_string()];
let banned = vec![];
let value = node.sample_according_to_restrictions(&parent_values, Some(&allowed), &banned);
assert_eq!(value, Some("1920".to_string()));
}
#[test]
fn test_sample_with_banned_values() {
let node = create_test_node();
let parent_values = HashMap::new();
let banned = vec!["1920".to_string(), "1366".to_string()];
for _ in 0..100 {
let value = node.sample_according_to_restrictions(&parent_values, None, &banned);
assert_eq!(value, Some("1536".to_string()));
}
}
#[test]
fn test_sample_returns_none_when_all_banned() {
let node = create_test_node();
let parent_values = HashMap::new();
let banned = vec!["1920".to_string(), "1366".to_string(), "1536".to_string()];
let value = node.sample_according_to_restrictions(&parent_values, None, &banned);
assert!(value.is_none());
}
}
+569
View File
@@ -0,0 +1,569 @@
//! Fingerprint generation module.
//!
//! Generates realistic browser fingerprints using Bayesian networks trained on real browser data.
pub mod bayesian_network;
pub mod bayesian_node;
pub mod types;
use bayesian_network::{BayesianNetwork, BayesianNetworkError};
use std::collections::HashMap;
use types::*;
use crate::camoufox::data;
/// Fingerprint generator using Bayesian networks.
pub struct FingerprintGenerator {
fingerprint_network: BayesianNetwork,
input_network: BayesianNetwork,
header_network: BayesianNetwork,
browser_helper: Vec<BrowserHttpInfo>,
headers_order: HashMap<String, Vec<String>>,
}
/// Parsed browser/HTTP version info.
#[derive(Debug, Clone)]
pub struct BrowserHttpInfo {
pub name: String,
pub version: Vec<u32>,
pub http_version: String,
pub complete_string: String,
}
impl BrowserHttpInfo {
fn parse(s: &str) -> Option<Self> {
if s == MISSING_VALUE_DATASET_TOKEN {
return None;
}
let parts: Vec<&str> = s.split('|').collect();
if parts.len() != 2 {
return None;
}
let browser_string = parts[0];
let http_version = parts[1].to_string();
let browser_parts: Vec<&str> = browser_string.split('/').collect();
if browser_parts.len() != 2 {
return None;
}
let name = browser_parts[0].to_string();
let version: Vec<u32> = browser_parts[1]
.split('.')
.filter_map(|v| v.parse().ok())
.collect();
Some(Self {
name,
version,
http_version,
complete_string: s.to_string(),
})
}
pub fn major_version(&self) -> u32 {
self.version.first().copied().unwrap_or(0)
}
}
/// Error type for fingerprint generation.
#[derive(Debug, thiserror::Error)]
pub enum FingerprintError {
#[error("Bayesian network error: {0}")]
Network(#[from] BayesianNetworkError),
#[error("JSON parsing error: {0}")]
Json(#[from] serde_json::Error),
#[error("Failed to generate consistent fingerprint after {0} attempts")]
GenerationFailed(u32),
#[error("No valid fingerprint generated")]
NoValidFingerprint,
}
impl FingerprintGenerator {
/// Create a new fingerprint generator.
pub fn new() -> Result<Self, FingerprintError> {
let fingerprint_network = BayesianNetwork::from_zip_bytes(data::FINGERPRINT_NETWORK_ZIP)?;
let input_network = BayesianNetwork::from_zip_bytes(data::INPUT_NETWORK_ZIP)?;
let header_network = BayesianNetwork::from_zip_bytes(data::HEADER_NETWORK_ZIP)?;
let browser_strings: Vec<String> = serde_json::from_str(data::BROWSER_HELPER_JSON)?;
let browser_helper: Vec<BrowserHttpInfo> = browser_strings
.iter()
.filter_map(|s| BrowserHttpInfo::parse(s))
.collect();
let headers_order: HashMap<String, Vec<String>> =
serde_json::from_str(data::HEADERS_ORDER_JSON)?;
Ok(Self {
fingerprint_network,
input_network,
header_network,
browser_helper,
headers_order,
})
}
/// Generate a fingerprint with matching headers.
pub fn get_fingerprint(
&self,
options: &FingerprintOptions,
) -> Result<FingerprintWithHeaders, FingerprintError> {
const MAX_RETRIES: u32 = 10;
// Build constraints from options
let mut value_possibilities = self.build_constraints(options);
// Handle screen constraints
let screen_values = if let Some(screen_constraints) = &options.screen {
self.filter_screen_values(screen_constraints)
} else {
None
};
if let Some(sv) = screen_values {
value_possibilities.insert("screen".to_string(), sv);
}
for attempt in 0..MAX_RETRIES {
// Generate input sample consistent with constraints
let input_sample = self
.input_network
.generate_consistent_sample_when_possible(&value_possibilities);
let Some(input_sample) = input_sample else {
continue;
};
// Generate header sample from input
let header_sample = self.header_network.generate_sample(&input_sample);
// Extract user agent
let user_agent = header_sample
.get("user-agent")
.or_else(|| header_sample.get("User-Agent"))
.cloned()
.unwrap_or_default();
// Build fingerprint constraints with the generated user agent
let mut fp_constraints = value_possibilities.clone();
fp_constraints.insert("userAgent".to_string(), vec![user_agent.clone()]);
// Generate fingerprint sample
let fingerprint_sample = self
.fingerprint_network
.generate_consistent_sample_when_possible(&fp_constraints);
let Some(fp_sample) = fingerprint_sample else {
log::debug!(
"Failed to generate fingerprint on attempt {}, retrying",
attempt + 1
);
continue;
};
// Transform the sample to a Fingerprint struct
match self.transform_sample(&fp_sample, &header_sample, options) {
Ok(result) => return Ok(result),
Err(e) => {
log::debug!(
"Failed to transform fingerprint on attempt {}: {}",
attempt + 1,
e
);
continue;
}
}
}
Err(FingerprintError::GenerationFailed(MAX_RETRIES))
}
/// Build constraint map from options.
fn build_constraints(&self, options: &FingerprintOptions) -> HashMap<String, Vec<String>> {
let mut constraints = HashMap::new();
// Operating system constraint
if let Some(os) = &options.operating_system {
constraints.insert(OPERATING_SYSTEM_NODE_NAME.to_string(), vec![os.clone()]);
}
// Device constraint (default to desktop)
let devices = options
.devices
.clone()
.unwrap_or_else(|| vec!["desktop".to_string()]);
constraints.insert(DEVICE_NODE_NAME.to_string(), devices);
// Browser constraint
let browsers = options
.browsers
.clone()
.unwrap_or_else(|| SUPPORTED_BROWSERS.iter().map(|s| s.to_string()).collect());
let http_version = options
.http_version
.clone()
.unwrap_or_else(|| "2".to_string());
// Filter browser helper entries by browser names and HTTP version
let browser_http_values: Vec<String> = self
.browser_helper
.iter()
.filter(|bh| browsers.contains(&bh.name) && bh.http_version == http_version)
.map(|bh| bh.complete_string.clone())
.collect();
if !browser_http_values.is_empty() {
constraints.insert(BROWSER_HTTP_NODE_NAME.to_string(), browser_http_values);
}
constraints
}
/// Filter screen values based on constraints.
fn filter_screen_values(&self, constraints: &ScreenConstraints) -> Option<Vec<String>> {
let possible_values = self.fingerprint_network.get_possible_values("screen")?;
let filtered: Vec<String> = possible_values
.into_iter()
.filter(|screen_str| {
// Screen values are stored as "*STRINGIFIED*{...json...}"
if let Some(json_str) = screen_str.strip_prefix(STRINGIFIED_PREFIX) {
if let Ok(screen) = serde_json::from_str::<serde_json::Value>(json_str) {
let width = screen["width"].as_u64().unwrap_or(0) as u32;
let height = screen["height"].as_u64().unwrap_or(0) as u32;
return constraints.matches(width, height);
}
}
true
})
.collect();
if filtered.is_empty() {
None
} else {
Some(filtered)
}
}
/// Transform raw sample data into a Fingerprint struct.
fn transform_sample(
&self,
fp_sample: &HashMap<String, String>,
header_sample: &HashMap<String, String>,
options: &FingerprintOptions,
) -> Result<FingerprintWithHeaders, FingerprintError> {
// Parse values, handling STRINGIFIED prefix and MISSING_VALUE token
let mut parsed: HashMap<String, serde_json::Value> = HashMap::new();
for (key, value) in fp_sample {
if value == MISSING_VALUE_DATASET_TOKEN {
continue;
}
let parsed_value = if let Some(json_str) = value.strip_prefix(STRINGIFIED_PREFIX) {
serde_json::from_str(json_str)?
} else {
serde_json::Value::String(value.clone())
};
parsed.insert(key.clone(), parsed_value);
}
// Check if screen was generated
let screen_value = parsed.get("screen");
if screen_value.is_none() {
return Err(FingerprintError::NoValidFingerprint);
}
// Extract screen fingerprint
let screen = if let Some(screen_val) = screen_value {
serde_json::from_value(screen_val.clone()).unwrap_or_default()
} else {
ScreenFingerprint::default()
};
// Build languages from Accept-Language header
let accept_language = header_sample
.get("accept-language")
.or_else(|| header_sample.get("Accept-Language"))
.cloned()
.unwrap_or_else(|| "en-US".to_string());
let languages: Vec<String> = accept_language
.split(',')
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
.collect();
let language = languages
.first()
.cloned()
.unwrap_or_else(|| "en-US".to_string());
// Build navigator fingerprint
let navigator = NavigatorFingerprint {
user_agent: get_string(&parsed, "userAgent"),
user_agent_data: parsed
.get("userAgentData")
.and_then(|v| serde_json::from_value(v.clone()).ok()),
do_not_track: parsed
.get("doNotTrack")
.and_then(|v| v.as_str().map(String::from)),
app_code_name: get_string_or(&parsed, "appCodeName", "Mozilla"),
app_name: get_string_or(&parsed, "appName", "Netscape"),
app_version: get_string(&parsed, "appVersion"),
oscpu: parsed
.get("oscpu")
.and_then(|v| v.as_str().map(String::from)),
webdriver: parsed
.get("webdriver")
.and_then(|v| v.as_str().map(String::from)),
language,
languages,
platform: get_string(&parsed, "platform"),
device_memory: parsed
.get("deviceMemory")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok()),
hardware_concurrency: parsed
.get("hardwareConcurrency")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.unwrap_or(4),
product: get_string_or(&parsed, "product", "Gecko"),
product_sub: get_string(&parsed, "productSub"),
vendor: get_string(&parsed, "vendor"),
vendor_sub: get_string(&parsed, "vendorSub"),
max_touch_points: parsed
.get("maxTouchPoints")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.unwrap_or(0),
extra_properties: parsed
.get("extraProperties")
.and_then(|v| serde_json::from_value(v.clone()).ok()),
};
// Build video card (will be filled later by WebGL sampler)
let video_card = parsed
.get("videoCard")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
// Build other components
let audio_codecs = parsed
.get("audioCodecs")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let video_codecs = parsed
.get("videoCodecs")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let plugins_data = parsed
.get("pluginsData")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let battery = parsed
.get("battery")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let multimedia_devices = parsed
.get("multimediaDevices")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let fonts = parsed
.get("fonts")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let fingerprint = Fingerprint {
screen,
navigator,
video_codecs,
audio_codecs,
plugins_data,
battery,
video_card,
multimedia_devices,
fonts,
mock_web_rtc: options.mock_web_rtc,
slim: options.slim,
};
// Build headers (filter out internal nodes and missing values)
let headers: Headers = header_sample
.iter()
.filter(|(k, v)| !k.starts_with('*') && *v != MISSING_VALUE_DATASET_TOKEN)
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
// Order headers
let ordered_headers = self.order_headers(&headers, &fingerprint.navigator.user_agent);
Ok(FingerprintWithHeaders {
fingerprint,
headers: ordered_headers,
})
}
/// Order headers according to browser-specific ordering.
fn order_headers(&self, headers: &Headers, user_agent: &str) -> Headers {
let browser = detect_browser_from_ua(user_agent);
let order = self.headers_order.get(browser).cloned().unwrap_or_default();
let mut ordered = HashMap::new();
// Add headers in order
for header_name in &order {
if let Some(value) = headers.get(header_name) {
ordered.insert(header_name.clone(), value.clone());
}
}
// Add remaining headers not in order
for (key, value) in headers {
if !order.contains(key) {
ordered.insert(key.clone(), value.clone());
}
}
ordered
}
}
fn get_string(map: &HashMap<String, serde_json::Value>, key: &str) -> String {
map
.get(key)
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default()
}
fn get_string_or(map: &HashMap<String, serde_json::Value>, key: &str, default: &str) -> String {
map
.get(key)
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| default.to_string())
}
fn detect_browser_from_ua(user_agent: &str) -> &str {
let ua_lower = user_agent.to_lowercase();
if ua_lower.contains("firefox") {
"firefox"
} else if ua_lower.contains("edg/") || ua_lower.contains("edge") {
"edge"
} else if ua_lower.contains("chrome") {
"chrome"
} else if ua_lower.contains("safari") {
"safari"
} else {
"chrome"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_generator() {
let generator = FingerprintGenerator::new();
assert!(
generator.is_ok(),
"Failed to create generator: {:?}",
generator.err()
);
}
#[test]
fn test_generate_fingerprint() {
let generator = FingerprintGenerator::new().unwrap();
let options = FingerprintOptions::default();
let result = generator.get_fingerprint(&options);
assert!(
result.is_ok(),
"Failed to generate fingerprint: {:?}",
result.err()
);
if let Ok(fp) = result {
assert!(!fp.fingerprint.navigator.user_agent.is_empty());
assert!(fp.fingerprint.screen.width > 0);
assert!(fp.fingerprint.screen.height > 0);
}
}
#[test]
fn test_generate_firefox_fingerprint() {
let generator = FingerprintGenerator::new().unwrap();
let options = FingerprintOptions {
browsers: Some(vec!["firefox".to_string()]),
..Default::default()
};
let result = generator.get_fingerprint(&options);
assert!(result.is_ok(), "Failed to generate Firefox fingerprint");
if let Ok(fp) = result {
assert!(
fp.fingerprint
.navigator
.user_agent
.to_lowercase()
.contains("firefox"),
"User agent should contain Firefox: {}",
fp.fingerprint.navigator.user_agent
);
}
}
#[test]
fn test_generate_with_screen_constraints() {
let generator = FingerprintGenerator::new().unwrap();
let options = FingerprintOptions {
screen: Some(ScreenConstraints {
min_width: Some(1900),
max_width: Some(1920),
min_height: Some(1000),
max_height: Some(1100),
}),
..Default::default()
};
let result = generator.get_fingerprint(&options);
assert!(
result.is_ok(),
"Failed to generate fingerprint with screen constraints"
);
if let Ok(fp) = result {
assert!(
fp.fingerprint.screen.width >= 1900 && fp.fingerprint.screen.width <= 1920,
"Screen width {} should be between 1900 and 1920",
fp.fingerprint.screen.width
);
}
}
#[test]
fn test_browser_http_info_parse() {
let info = BrowserHttpInfo::parse("chrome/143.0.0.0|2");
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.name, "chrome");
assert_eq!(info.major_version(), 143);
assert_eq!(info.http_version, "2");
}
}
+302
View File
@@ -0,0 +1,302 @@
//! Fingerprint type definitions.
//!
//! These types represent browser fingerprints that can be injected into Camoufox.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A complete browser fingerprint.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Fingerprint {
pub screen: ScreenFingerprint,
pub navigator: NavigatorFingerprint,
#[serde(default)]
pub video_codecs: HashMap<String, String>,
#[serde(default)]
pub audio_codecs: HashMap<String, String>,
#[serde(default)]
pub plugins_data: HashMap<String, String>,
#[serde(default)]
pub battery: Option<BatteryFingerprint>,
pub video_card: VideoCard,
#[serde(default)]
pub multimedia_devices: Vec<String>,
#[serde(default)]
pub fonts: Vec<String>,
#[serde(default)]
pub mock_web_rtc: bool,
#[serde(default)]
pub slim: bool,
}
/// Screen-related fingerprint properties.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ScreenFingerprint {
pub width: u32,
pub height: u32,
pub avail_width: u32,
pub avail_height: u32,
#[serde(default)]
pub avail_top: u32,
#[serde(default)]
pub avail_left: u32,
pub color_depth: u32,
pub pixel_depth: u32,
#[serde(default = "default_device_pixel_ratio")]
pub device_pixel_ratio: f64,
#[serde(default)]
pub page_x_offset: f64,
#[serde(default)]
pub page_y_offset: f64,
pub inner_width: u32,
pub inner_height: u32,
pub outer_width: u32,
pub outer_height: u32,
#[serde(default)]
pub screen_x: i32,
#[serde(default)]
pub screen_y: i32,
#[serde(default)]
pub client_width: Option<u32>,
#[serde(default)]
pub client_height: Option<u32>,
#[serde(default)]
pub has_hdr: bool,
}
fn default_device_pixel_ratio() -> f64 {
1.0
}
/// Brand information for User-Agent Client Hints.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Brand {
pub brand: String,
pub version: String,
}
/// User-Agent Client Hints data.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct UserAgentData {
#[serde(default)]
pub brands: Vec<Brand>,
#[serde(default)]
pub mobile: bool,
#[serde(default)]
pub platform: String,
#[serde(default)]
pub architecture: String,
#[serde(default)]
pub bitness: String,
#[serde(default)]
pub full_version_list: Vec<Brand>,
#[serde(default)]
pub model: String,
#[serde(default)]
pub platform_version: String,
#[serde(default)]
pub ua_full_version: String,
}
/// Extra navigator properties.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ExtraProperties {
#[serde(default)]
pub vendor_flavors: Vec<String>,
#[serde(default)]
pub is_bluetooth_supported: bool,
#[serde(default)]
pub global_privacy_control: Option<bool>,
#[serde(default = "default_pdf_viewer_enabled")]
pub pdf_viewer_enabled: bool,
#[serde(default)]
pub installed_apps: Vec<serde_json::Value>,
}
fn default_pdf_viewer_enabled() -> bool {
true
}
/// Navigator-related fingerprint properties.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct NavigatorFingerprint {
pub user_agent: String,
#[serde(default)]
pub user_agent_data: Option<UserAgentData>,
#[serde(default)]
pub do_not_track: Option<String>,
#[serde(default = "default_app_code_name")]
pub app_code_name: String,
#[serde(default = "default_app_name")]
pub app_name: String,
#[serde(default)]
pub app_version: String,
#[serde(default)]
pub oscpu: Option<String>,
#[serde(default)]
pub webdriver: Option<String>,
pub language: String,
pub languages: Vec<String>,
pub platform: String,
#[serde(default)]
pub device_memory: Option<u32>,
pub hardware_concurrency: u32,
#[serde(default = "default_product")]
pub product: String,
#[serde(default)]
pub product_sub: String,
#[serde(default)]
pub vendor: String,
#[serde(default)]
pub vendor_sub: String,
#[serde(default)]
pub max_touch_points: u32,
#[serde(default)]
pub extra_properties: Option<ExtraProperties>,
}
fn default_app_code_name() -> String {
"Mozilla".to_string()
}
fn default_app_name() -> String {
"Netscape".to_string()
}
fn default_product() -> String {
"Gecko".to_string()
}
/// WebGL video card information.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VideoCard {
pub vendor: String,
pub renderer: String,
}
/// Battery status fingerprint.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatteryFingerprint {
pub charging: bool,
pub charging_time: f64,
pub discharging_time: f64,
pub level: f64,
}
/// HTTP headers for a fingerprint.
pub type Headers = HashMap<String, String>;
/// A fingerprint combined with matching HTTP headers.
#[derive(Debug, Clone)]
pub struct FingerprintWithHeaders {
pub fingerprint: Fingerprint,
pub headers: Headers,
}
/// Options for generating fingerprints.
#[derive(Debug, Clone, Default)]
pub struct FingerprintOptions {
/// Target operating system: "windows", "macos", "linux"
pub operating_system: Option<String>,
/// Target browser: "firefox", "chrome", "safari", "edge"
pub browsers: Option<Vec<String>>,
/// Target device type: "desktop", "mobile"
pub devices: Option<Vec<String>>,
/// Locales for Accept-Language header
pub locales: Option<Vec<String>>,
/// HTTP version: "1" or "2"
pub http_version: Option<String>,
/// Screen dimension constraints
pub screen: Option<ScreenConstraints>,
/// Whether to mock WebRTC
pub mock_web_rtc: bool,
/// Slim mode (fewer evasions)
pub slim: bool,
}
/// Constraints for screen dimensions.
#[derive(Debug, Clone, Default)]
pub struct ScreenConstraints {
pub min_width: Option<u32>,
pub max_width: Option<u32>,
pub min_height: Option<u32>,
pub max_height: Option<u32>,
}
impl ScreenConstraints {
pub fn new() -> Self {
Self::default()
}
pub fn with_min_width(mut self, width: u32) -> Self {
self.min_width = Some(width);
self
}
pub fn with_max_width(mut self, width: u32) -> Self {
self.max_width = Some(width);
self
}
pub fn with_min_height(mut self, height: u32) -> Self {
self.min_height = Some(height);
self
}
pub fn with_max_height(mut self, height: u32) -> Self {
self.max_height = Some(height);
self
}
/// Check if a screen size matches these constraints.
pub fn matches(&self, width: u32, height: u32) -> bool {
if let Some(min_w) = self.min_width {
if width < min_w {
return false;
}
}
if let Some(max_w) = self.max_width {
if width > max_w {
return false;
}
}
if let Some(min_h) = self.min_height {
if height < min_h {
return false;
}
}
if let Some(max_h) = self.max_height {
if height > max_h {
return false;
}
}
true
}
}
/// Constants used in fingerprint generation.
pub const MISSING_VALUE_DATASET_TOKEN: &str = "*MISSING_VALUE*";
pub const STRINGIFIED_PREFIX: &str = "*STRINGIFIED*";
/// Special node names in the Bayesian networks.
pub const BROWSER_HTTP_NODE_NAME: &str = "*BROWSER_HTTP";
pub const OPERATING_SYSTEM_NODE_NAME: &str = "*OPERATING_SYSTEM";
pub const DEVICE_NODE_NAME: &str = "*DEVICE";
/// Supported browsers.
pub const SUPPORTED_BROWSERS: &[&str] = &["chrome", "firefox", "safari", "edge"];
/// Supported operating systems.
pub const SUPPORTED_OPERATING_SYSTEMS: &[&str] = &["windows", "macos", "linux", "android", "ios"];
/// Supported devices.
pub const SUPPORTED_DEVICES: &[&str] = &["desktop", "mobile"];
/// Supported HTTP versions.
pub const SUPPORTED_HTTP_VERSIONS: &[&str] = &["1", "2"];
+83
View File
@@ -0,0 +1,83 @@
//! OS-specific font lists for Camoufox.
//!
//! Provides default system fonts for Windows, macOS, and Linux.
use std::collections::HashMap;
use crate::camoufox::data;
/// Get fonts for the target OS.
pub fn get_fonts_for_os(target_os: &str) -> Vec<String> {
let fonts_map: HashMap<String, Vec<String>> =
serde_json::from_str(data::FONTS_JSON).unwrap_or_default();
let os_key = match target_os {
"win" | "windows" => "win",
"mac" | "macos" => "mac",
"lin" | "linux" => "lin",
_ => "win", // Default to Windows fonts
};
fonts_map.get(os_key).cloned().unwrap_or_default()
}
/// Get fonts for the target OS with additional custom fonts.
pub fn get_fonts_with_custom(target_os: &str, custom_fonts: Option<&[String]>) -> Vec<String> {
let mut fonts = get_fonts_for_os(target_os);
if let Some(custom) = custom_fonts {
// Add custom fonts, avoiding duplicates
for font in custom {
if !fonts.contains(font) {
fonts.push(font.clone());
}
}
}
fonts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_fonts_for_windows() {
let fonts = get_fonts_for_os("win");
assert!(!fonts.is_empty());
assert!(fonts.contains(&"Arial".to_string()));
assert!(fonts.contains(&"Calibri".to_string()));
}
#[test]
fn test_get_fonts_for_macos() {
let fonts = get_fonts_for_os("mac");
assert!(!fonts.is_empty());
assert!(fonts.contains(&"Helvetica".to_string()));
}
#[test]
fn test_get_fonts_for_linux() {
let fonts = get_fonts_for_os("lin");
assert!(!fonts.is_empty());
}
#[test]
fn test_get_fonts_with_custom() {
let custom = vec!["MyCustomFont".to_string()];
let fonts = get_fonts_with_custom("win", Some(&custom));
assert!(fonts.contains(&"MyCustomFont".to_string()));
assert!(fonts.contains(&"Arial".to_string()));
}
#[test]
fn test_fonts_no_duplicates() {
let custom = vec!["Arial".to_string()]; // Arial already exists in Windows fonts
let fonts = get_fonts_with_custom("win", Some(&custom));
// Count occurrences of Arial
let arial_count = fonts.iter().filter(|f| *f == "Arial").count();
assert_eq!(arial_count, 1);
}
}
+541
View File
@@ -0,0 +1,541 @@
//! Geolocation support for Camoufox fingerprinting.
//!
//! This module provides IP-based geolocation lookup using the MaxMind GeoLite2 database,
//! and locale generation based on country/territory information.
use crate::camoufox::data;
use crate::geoip_downloader::GeoIPDownloader;
use directories::BaseDirs;
use maxminddb::{geoip2, Reader};
use quick_xml::events::Event;
use quick_xml::Reader as XmlReader;
use rand::Rng;
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::PathBuf;
use std::str::FromStr;
/// Geolocation error type.
#[derive(Debug, thiserror::Error)]
pub enum GeolocationError {
#[error("GeoIP database not found. Please download it first.")]
DatabaseNotFound,
#[error("Failed to open GeoIP database: {0}")]
DatabaseOpen(String),
#[error("Invalid IP address: {0}")]
InvalidIP(String),
#[error("IP location not found: {0}")]
LocationNotFound(String),
#[error("Unknown territory: {0}")]
UnknownTerritory(String),
#[error("No language data for territory: {0}")]
NoLanguageData(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Network error: {0}")]
Network(String),
}
/// Locale information.
#[derive(Debug, Clone)]
pub struct Locale {
pub language: String,
pub region: Option<String>,
pub script: Option<String>,
}
impl Locale {
/// Format locale as a string (e.g., "en-US").
pub fn as_string(&self) -> String {
if let Some(region) = &self.region {
format!("{}-{}", self.language, region)
} else {
self.language.clone()
}
}
/// Convert to config format for Camoufox.
pub fn as_config(&self) -> HashMap<String, serde_json::Value> {
let mut config = HashMap::new();
if let Some(region) = &self.region {
config.insert(
"locale:region".to_string(),
serde_json::json!(region.to_uppercase()),
);
}
config.insert(
"locale:language".to_string(),
serde_json::json!(self.language.clone()),
);
if let Some(script) = &self.script {
config.insert("locale:script".to_string(), serde_json::json!(script));
}
config
}
}
/// Geolocation information.
#[derive(Debug, Clone)]
pub struct Geolocation {
pub locale: Locale,
pub longitude: f64,
pub latitude: f64,
pub timezone: String,
pub accuracy: Option<f64>,
}
impl Geolocation {
/// Convert to config format for Camoufox.
pub fn as_config(&self) -> HashMap<String, serde_json::Value> {
let mut config = self.locale.as_config();
config.insert(
"geolocation:longitude".to_string(),
serde_json::json!(self.longitude),
);
config.insert(
"geolocation:latitude".to_string(),
serde_json::json!(self.latitude),
);
config.insert("timezone".to_string(), serde_json::json!(self.timezone));
if let Some(accuracy) = self.accuracy {
config.insert(
"geolocation:accuracy".to_string(),
serde_json::json!(accuracy),
);
}
config
}
}
/// Territory language population data.
struct LanguagePopulation {
language: String,
population_percent: f64,
}
/// Statistical locale selector based on territory language populations.
pub struct LocaleSelector {
territories: HashMap<String, Vec<LanguagePopulation>>,
}
impl LocaleSelector {
/// Create a new locale selector by parsing territory info XML.
pub fn new() -> Result<Self, GeolocationError> {
let mut territories: HashMap<String, Vec<LanguagePopulation>> = HashMap::new();
let mut reader = XmlReader::from_str(data::TERRITORY_INFO_XML);
reader.config_mut().trim_text(true);
let mut current_territory: Option<String> = None;
let mut current_languages: Vec<LanguagePopulation> = Vec::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
let name = e.name();
let name_str = std::str::from_utf8(name.as_ref()).unwrap_or("");
if name_str == "territory" {
// Save previous territory if exists
if let Some(code) = current_territory.take() {
if !current_languages.is_empty() {
territories.insert(code, std::mem::take(&mut current_languages));
}
}
// Get territory type attribute
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"type" {
current_territory = Some(String::from_utf8_lossy(&attr.value).to_uppercase());
}
}
} else if name_str == "languagePopulation" && current_territory.is_some() {
let mut lang_type = None;
let mut pop_percent = 0.0;
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"type" => {
lang_type = Some(String::from_utf8_lossy(&attr.value).to_string());
}
b"populationPercent" => {
pop_percent = String::from_utf8_lossy(&attr.value).parse().unwrap_or(0.0);
}
_ => {}
}
}
if let Some(lang) = lang_type {
current_languages.push(LanguagePopulation {
language: lang.replace('_', "-"),
population_percent: pop_percent,
});
}
}
}
Ok(Event::End(ref e)) => {
let name_ref = e.name();
let name = std::str::from_utf8(name_ref.as_ref()).unwrap_or("");
if name == "territory" {
// Save territory
if let Some(code) = current_territory.take() {
if !current_languages.is_empty() {
territories.insert(code, std::mem::take(&mut current_languages));
}
}
}
}
Ok(Event::Eof) => break,
Err(e) => {
log::warn!("Error parsing territory XML: {}", e);
break;
}
_ => {}
}
buf.clear();
}
Ok(Self { territories })
}
/// Get a locale for a given region/country code.
pub fn from_region(&self, region: &str) -> Result<Locale, GeolocationError> {
let region_upper = region.to_uppercase();
let languages = self
.territories
.get(&region_upper)
.ok_or_else(|| GeolocationError::UnknownTerritory(region.to_string()))?;
if languages.is_empty() {
return Err(GeolocationError::NoLanguageData(region.to_string()));
}
// Weighted random selection based on population percentage
let total: f64 = languages.iter().map(|l| l.population_percent).sum();
let mut rng = rand::rng();
let target = rng.random::<f64>() * total;
let mut cumulative = 0.0;
for lang in languages {
cumulative += lang.population_percent;
if cumulative >= target {
return Ok(normalize_locale(&format!(
"{}-{}",
lang.language, region_upper
)));
}
}
// Fallback to first language
let first_lang = &languages[0].language;
Ok(normalize_locale(&format!(
"{}-{}",
first_lang, region_upper
)))
}
}
impl Default for LocaleSelector {
fn default() -> Self {
Self::new().unwrap_or(Self {
territories: HashMap::new(),
})
}
}
/// Normalize a locale string to standard format.
fn normalize_locale(locale: &str) -> Locale {
let parts: Vec<&str> = locale.split('-').collect();
let language = parts
.first()
.map(|s| s.to_lowercase())
.unwrap_or_else(|| "en".to_string());
let region = parts.get(1).map(|s| s.to_uppercase());
// Determine script based on language if needed
let script = match language.as_str() {
"zh" => {
// Chinese - Traditional for TW/HK, Simplified otherwise
if region.as_deref() == Some("TW") || region.as_deref() == Some("HK") {
Some("Hant".to_string())
} else {
Some("Hans".to_string())
}
}
"sr" => {
// Serbian - can be Cyrillic or Latin
Some("Cyrl".to_string())
}
_ => None,
};
Locale {
language,
region,
script,
}
}
/// Get the path to the GeoIP MMDB file.
fn get_mmdb_path() -> Result<PathBuf, GeolocationError> {
let base_dirs = BaseDirs::new().ok_or(GeolocationError::DatabaseNotFound)?;
#[cfg(target_os = "windows")]
let cache_dir = base_dirs
.data_local_dir()
.join("camoufox")
.join("camoufox")
.join("Cache");
#[cfg(target_os = "macos")]
let cache_dir = base_dirs.cache_dir().join("camoufox");
#[cfg(target_os = "linux")]
let cache_dir = base_dirs.cache_dir().join("camoufox");
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
let cache_dir = base_dirs.cache_dir().join("camoufox");
Ok(cache_dir.join("GeoLite2-City.mmdb"))
}
/// Check if the GeoIP database is available.
pub fn is_geoip_available() -> bool {
GeoIPDownloader::is_geoip_database_available()
}
/// Get geolocation information for an IP address.
pub fn get_geolocation(ip: &str) -> Result<Geolocation, GeolocationError> {
let mmdb_path = get_mmdb_path()?;
if !mmdb_path.exists() {
return Err(GeolocationError::DatabaseNotFound);
}
let reader =
Reader::open_readfile(&mmdb_path).map_err(|e| GeolocationError::DatabaseOpen(e.to_string()))?;
let ip_addr: IpAddr =
IpAddr::from_str(ip).map_err(|_| GeolocationError::InvalidIP(ip.to_string()))?;
let city: geoip2::City = reader
.lookup(ip_addr)
.map_err(|e| GeolocationError::LocationNotFound(e.to_string()))?;
// Extract location data
let location = city
.location
.ok_or_else(|| GeolocationError::LocationNotFound(ip.to_string()))?;
let longitude = location
.longitude
.ok_or_else(|| GeolocationError::LocationNotFound("No longitude".to_string()))?;
let latitude = location
.latitude
.ok_or_else(|| GeolocationError::LocationNotFound("No latitude".to_string()))?;
let timezone = location
.time_zone
.ok_or_else(|| GeolocationError::LocationNotFound("No timezone".to_string()))?
.to_string();
// Get country code
let country = city
.country
.ok_or_else(|| GeolocationError::LocationNotFound("No country".to_string()))?;
let iso_code = country
.iso_code
.ok_or_else(|| GeolocationError::LocationNotFound("No country code".to_string()))?
.to_uppercase();
// Get locale from territory data
let selector = LocaleSelector::new()?;
let locale = selector.from_region(&iso_code)?;
Ok(Geolocation {
locale,
longitude,
latitude,
timezone,
accuracy: location.accuracy_radius.map(|r| r as f64),
})
}
/// Validate an IP address (IPv4 or IPv6).
pub fn validate_ip(ip: &str) -> bool {
IpAddr::from_str(ip).is_ok()
}
/// Check if an IP is IPv4.
pub fn is_ipv4(ip: &str) -> bool {
if let Ok(addr) = IpAddr::from_str(ip) {
addr.is_ipv4()
} else {
false
}
}
/// Check if an IP is IPv6.
pub fn is_ipv6(ip: &str) -> bool {
if let Ok(addr) = IpAddr::from_str(ip) {
addr.is_ipv6()
} else {
false
}
}
/// Fetch public IP address, optionally through a proxy.
pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, GeolocationError> {
let urls = [
"https://api.ipify.org",
"https://checkip.amazonaws.com",
"https://ipinfo.io/ip",
"https://icanhazip.com",
"https://ifconfig.co/ip",
"https://ipecho.net/plain",
];
let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(5));
let client = if let Some(proxy_url) = proxy {
let proxy = reqwest::Proxy::all(proxy_url)
.map_err(|e| GeolocationError::Network(format!("Invalid proxy: {}", e)))?;
client_builder
.proxy(proxy)
.build()
.map_err(|e| GeolocationError::Network(e.to_string()))?
} else {
client_builder
.build()
.map_err(|e| GeolocationError::Network(e.to_string()))?
};
let mut last_error = None;
for url in &urls {
match client.get(*url).send().await {
Ok(response) if response.status().is_success() => match response.text().await {
Ok(text) => {
let ip = text.trim().to_string();
if validate_ip(&ip) {
return Ok(ip);
}
}
Err(e) => {
last_error = Some(format!("Failed to read response from {}: {}", url, e));
}
},
Ok(response) => {
last_error = Some(format!("HTTP {} from {}", response.status(), url));
}
Err(e) => {
last_error = Some(format!("Request to {} failed: {}", url, e));
}
}
}
Err(GeolocationError::Network(last_error.unwrap_or_else(|| {
"Failed to fetch public IP from any endpoint".to_string()
})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_locale_selector_creation() {
let selector = LocaleSelector::new();
assert!(selector.is_ok());
}
#[test]
fn test_locale_from_region() {
let selector = LocaleSelector::new().unwrap();
// Test common regions
let us_locale = selector.from_region("US");
assert!(us_locale.is_ok());
let us = us_locale.unwrap();
assert_eq!(us.region, Some("US".to_string()));
let de_locale = selector.from_region("DE");
assert!(de_locale.is_ok());
let de = de_locale.unwrap();
assert_eq!(de.region, Some("DE".to_string()));
}
#[test]
fn test_locale_as_string() {
let locale = Locale {
language: "en".to_string(),
region: Some("US".to_string()),
script: None,
};
assert_eq!(locale.as_string(), "en-US");
let locale_no_region = Locale {
language: "en".to_string(),
region: None,
script: None,
};
assert_eq!(locale_no_region.as_string(), "en");
}
#[test]
fn test_validate_ip() {
assert!(validate_ip("8.8.8.8"));
assert!(validate_ip("192.168.1.1"));
assert!(validate_ip("2001:4860:4860::8888"));
assert!(!validate_ip("invalid"));
assert!(!validate_ip("256.256.256.256"));
}
#[test]
fn test_is_ipv4() {
assert!(is_ipv4("8.8.8.8"));
assert!(!is_ipv4("2001:4860:4860::8888"));
assert!(!is_ipv4("invalid"));
}
#[test]
fn test_is_ipv6() {
assert!(is_ipv6("2001:4860:4860::8888"));
assert!(!is_ipv6("8.8.8.8"));
assert!(!is_ipv6("invalid"));
}
#[test]
fn test_normalize_locale() {
let locale = normalize_locale("en-US");
assert_eq!(locale.language, "en");
assert_eq!(locale.region, Some("US".to_string()));
assert!(locale.script.is_none());
let zh_tw = normalize_locale("zh-TW");
assert_eq!(zh_tw.language, "zh");
assert_eq!(zh_tw.region, Some("TW".to_string()));
assert_eq!(zh_tw.script, Some("Hant".to_string()));
let zh_cn = normalize_locale("zh-CN");
assert_eq!(zh_cn.script, Some("Hans".to_string()));
}
}
+338
View File
@@ -0,0 +1,338 @@
//! Camoufox browser launcher using playwright-rust.
//!
//! Provides functionality to launch Camoufox browser instances with fingerprint injection.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use playwright::api::{Browser, BrowserContext, Playwright, ProxySettings};
use playwright::Error as PlaywrightError;
use crate::camoufox::config::{CamoufoxConfigBuilder, CamoufoxLaunchConfig, ProxyConfig};
use crate::camoufox::fingerprint::types::{Fingerprint, ScreenConstraints};
/// Camoufox launcher for creating browser instances.
pub struct CamoufoxLauncher {
playwright: Arc<Playwright>,
executable_path: PathBuf,
}
/// Error type for launcher operations.
#[derive(Debug, thiserror::Error)]
pub enum LauncherError {
#[error("Playwright error: {0}")]
Playwright(PlaywrightError),
#[error("Playwright Arc error: {0}")]
PlaywrightArc(#[from] Arc<PlaywrightError>),
#[error("Configuration error: {0}")]
Config(#[from] crate::camoufox::config::ConfigError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Camoufox executable not found at: {0}")]
ExecutableNotFound(PathBuf),
#[error("Failed to generate environment variables: {0}")]
EnvVars(#[from] serde_json::Error),
}
/// Options for launching Camoufox.
#[derive(Debug, Clone, Default)]
pub struct LaunchOptions {
/// Operating system to spoof: "windows", "macos", "linux"
pub os: Option<String>,
/// Block all images
pub block_images: bool,
/// Block WebRTC entirely
pub block_webrtc: bool,
/// Block WebGL (not recommended unless necessary)
pub block_webgl: bool,
/// Screen dimension constraints
pub screen: Option<ScreenConstraints>,
/// Fixed window size [width, height]
pub window: Option<(u32, u32)>,
/// Custom fingerprint (if not provided, one will be generated)
pub fingerprint: Option<Fingerprint>,
/// Run in headless mode
pub headless: bool,
/// Custom fonts to load
pub fonts: Option<Vec<String>>,
/// Only use custom fonts (disable OS fonts)
pub custom_fonts_only: bool,
/// Firefox user preferences
pub firefox_user_prefs: Option<HashMap<String, serde_json::Value>>,
/// Proxy configuration
pub proxy: Option<ProxyConfig>,
/// Additional browser arguments
pub args: Option<Vec<String>>,
/// Additional environment variables
pub env: Option<HashMap<String, String>>,
/// Profile/user data directory
pub user_data_dir: Option<PathBuf>,
/// Enable debug output
pub debug: bool,
}
impl CamoufoxLauncher {
/// Create a new Camoufox launcher.
pub async fn new(executable_path: impl AsRef<Path>) -> Result<Self, LauncherError> {
let executable_path = executable_path.as_ref().to_path_buf();
if !executable_path.exists() {
return Err(LauncherError::ExecutableNotFound(executable_path));
}
let playwright = Playwright::initialize()
.await
.map_err(LauncherError::Playwright)?;
Ok(Self {
playwright: Arc::new(playwright),
executable_path,
})
}
/// Launch a new Camoufox browser instance.
pub async fn launch(&self, options: LaunchOptions) -> Result<Browser, LauncherError> {
let config = self.build_config(&options)?;
if options.debug {
log::debug!("Camoufox config: {:?}", config.fingerprint_config);
}
// Get environment variables
let env_vars = config.get_env_vars()?;
// Build launch arguments
let mut args = options.args.clone().unwrap_or_default();
// Add headless flag if needed
if options.headless {
args.push("--headless".to_string());
}
// Merge environment variables
let mut env = options.env.clone().unwrap_or_default();
for (key, value) in env_vars {
env.insert(key, value);
}
// Handle fontconfig on Linux
if cfg!(target_os = "linux") {
if let Some(fontconfig_path) =
crate::camoufox::env_vars::get_fontconfig_env(&config.target_os, &self.executable_path)
{
env.insert("FONTCONFIG_PATH".to_string(), fontconfig_path);
}
}
// Build Firefox user prefs
let mut firefox_prefs = config.firefox_prefs.clone();
if let Some(user_prefs) = options.firefox_user_prefs {
for (key, value) in user_prefs {
firefox_prefs.insert(key, value);
}
}
// Get the Firefox browser type
let firefox = self.playwright.firefox();
// Build launch options
let mut launch_options = firefox.launcher();
launch_options = launch_options.executable(&self.executable_path);
launch_options = launch_options.headless(options.headless);
// Add args
if !args.is_empty() {
launch_options = launch_options.args(&args);
}
// Add environment as serde_json::Map
if !env.is_empty() {
let env_map: serde_json::Map<String, serde_json::Value> = env
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect();
launch_options = launch_options.env(env_map);
}
// Add proxy if configured
if let Some(proxy) = &config.proxy {
let proxy_settings = ProxySettings {
server: proxy.server.clone(),
username: proxy.username.clone(),
password: proxy.password.clone(),
bypass: proxy.bypass.clone(),
};
launch_options = launch_options.proxy(proxy_settings);
}
// Add Firefox preferences
if !firefox_prefs.is_empty() {
let prefs_map: serde_json::Map<String, serde_json::Value> =
firefox_prefs.into_iter().collect();
launch_options = launch_options.firefox_user_prefs(prefs_map);
}
// Launch the browser
let browser = launch_options.launch().await?;
Ok(browser)
}
/// Launch a persistent browser context.
pub async fn launch_persistent_context(
&self,
user_data_dir: impl AsRef<Path>,
options: LaunchOptions,
) -> Result<BrowserContext, LauncherError> {
let config = self.build_config(&options)?;
if options.debug {
log::debug!("Camoufox config: {:?}", config.fingerprint_config);
}
// Get environment variables
let env_vars = config.get_env_vars()?;
// Build launch arguments
let mut args = options.args.clone().unwrap_or_default();
if options.headless {
args.push("--headless".to_string());
}
// Merge environment variables
let mut env = options.env.clone().unwrap_or_default();
for (key, value) in env_vars {
env.insert(key, value);
}
// Handle fontconfig on Linux
if cfg!(target_os = "linux") {
if let Some(fontconfig_path) =
crate::camoufox::env_vars::get_fontconfig_env(&config.target_os, &self.executable_path)
{
env.insert("FONTCONFIG_PATH".to_string(), fontconfig_path);
}
}
// Build Firefox user prefs
let mut firefox_prefs = config.firefox_prefs.clone();
if let Some(user_prefs) = options.firefox_user_prefs {
for (key, value) in user_prefs {
firefox_prefs.insert(key, value);
}
}
// Get the Firefox browser type
let firefox = self.playwright.firefox();
// Build persistent context options
let mut context_options = firefox.persistent_context_launcher(user_data_dir.as_ref());
context_options = context_options.executable(&self.executable_path);
context_options = context_options.headless(options.headless);
// Add args
if !args.is_empty() {
context_options = context_options.args(&args);
}
// Add environment as serde_json::Map
if !env.is_empty() {
let env_map: serde_json::Map<String, serde_json::Value> = env
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect();
context_options = context_options.env(env_map);
}
// Add proxy if configured
if let Some(proxy) = &config.proxy {
let proxy_settings = ProxySettings {
server: proxy.server.clone(),
username: proxy.username.clone(),
password: proxy.password.clone(),
bypass: proxy.bypass.clone(),
};
context_options = context_options.proxy(proxy_settings);
}
// Note: PersistentContextLauncher doesn't support firefox_user_prefs
// Firefox preferences should be set via about:config or prefs.js in the profile
// Launch the persistent context
let context = context_options.launch().await?;
Ok(context)
}
/// Build Camoufox configuration from launch options.
fn build_config(&self, options: &LaunchOptions) -> Result<CamoufoxLaunchConfig, LauncherError> {
let mut builder = CamoufoxConfigBuilder::new();
if let Some(os) = &options.os {
builder = builder.operating_system(os);
}
if let Some(screen) = &options.screen {
builder = builder.screen_constraints(screen.clone());
}
if let Some(fingerprint) = &options.fingerprint {
builder = builder.fingerprint(fingerprint.clone());
}
builder = builder.block_images(options.block_images);
builder = builder.block_webrtc(options.block_webrtc);
builder = builder.block_webgl(options.block_webgl);
builder = builder.headless(options.headless);
if let Some(fonts) = &options.fonts {
builder = builder.custom_fonts(fonts.clone());
}
builder = builder.custom_fonts_only(options.custom_fonts_only);
if let Some(proxy) = &options.proxy {
builder = builder.proxy(proxy.clone());
}
// Get Firefox version from executable
if let Some(version) = crate::camoufox::config::get_firefox_version(&self.executable_path) {
builder = builder.ff_version(version);
}
Ok(builder.build()?)
}
/// Get the executable path.
pub fn executable_path(&self) -> &Path {
&self.executable_path
}
}
/// Convenience function to launch Camoufox with default settings.
pub async fn launch_camoufox(
executable_path: impl AsRef<Path>,
options: LaunchOptions,
) -> Result<Browser, LauncherError> {
let launcher = CamoufoxLauncher::new(executable_path).await?;
launcher.launch(options).await
}
/// Convenience function to launch a persistent Camoufox context.
pub async fn launch_persistent_camoufox(
executable_path: impl AsRef<Path>,
user_data_dir: impl AsRef<Path>,
options: LaunchOptions,
) -> Result<BrowserContext, LauncherError> {
let launcher = CamoufoxLauncher::new(executable_path).await?;
launcher
.launch_persistent_context(user_data_dir, options)
.await
}
+154
View File
@@ -0,0 +1,154 @@
//! Camoufox browser integration module.
//!
//! Provides native Rust support for launching Camoufox browsers with realistic
//! fingerprint injection using playwright-rust.
//!
//! # Overview
//!
//! This module replaces the previous Node.js-based nodecar implementation with
//! a pure Rust solution. Key components:
//!
//! - **Fingerprint Generation**: Bayesian network-based fingerprint generation
//! - **WebGL Sampling**: Realistic WebGL configurations from a SQLite database
//! - **Configuration Builder**: Converts fingerprints to Camoufox config format
//! - **Launcher**: playwright-rust integration for browser launching
//!
//! # Example
//!
//! ```rust,ignore
//! use donutbrowser_lib::camoufox::{CamoufoxLauncher, LaunchOptions};
//!
//! async fn launch_browser() -> Result<(), Box<dyn std::error::Error>> {
//! let launcher = CamoufoxLauncher::new("/path/to/camoufox").await?;
//!
//! let options = LaunchOptions {
//! os: Some("windows".to_string()),
//! headless: false,
//! ..Default::default()
//! };
//!
//! let browser = launcher.launch(options).await?;
//!
//! // Use the browser...
//!
//! browser.close().await?;
//! Ok(())
//! }
//! ```
pub mod config;
pub mod data;
pub mod env_vars;
pub mod fingerprint;
pub mod fonts;
pub mod geolocation;
pub mod launcher;
pub mod webgl;
// Re-export main types for convenience
pub use config::{
CamoufoxConfigBuilder, CamoufoxLaunchConfig, ConfigError, GeoIPOption, ProxyConfig,
};
pub use fingerprint::types::{
Fingerprint, FingerprintOptions, FingerprintWithHeaders, NavigatorFingerprint, ScreenConstraints,
ScreenFingerprint, VideoCard,
};
pub use fingerprint::{FingerprintError, FingerprintGenerator};
pub use geolocation::{
fetch_public_ip, get_geolocation, is_geoip_available, is_ipv4, is_ipv6, validate_ip, Geolocation,
GeolocationError, Locale, LocaleSelector,
};
pub use launcher::{
launch_camoufox, launch_persistent_camoufox, CamoufoxLauncher, LaunchOptions, LauncherError,
};
pub use webgl::{sample_webgl, WebGLData, WebGLError};
/// Unified error type for all Camoufox operations.
#[derive(Debug, thiserror::Error)]
pub enum CamoufoxError {
#[error("Launcher error: {0}")]
Launcher(#[from] LauncherError),
#[error("Configuration error: {0}")]
Config(#[from] ConfigError),
#[error("Fingerprint error: {0}")]
Fingerprint(#[from] FingerprintError),
#[error("WebGL error: {0}")]
WebGL(#[from] WebGLError),
#[error("Geolocation error: {0}")]
Geolocation(#[from] GeolocationError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fingerprint_generation() {
let generator = FingerprintGenerator::new().unwrap();
let options = FingerprintOptions {
browsers: Some(vec!["firefox".to_string()]),
operating_system: Some("windows".to_string()),
..Default::default()
};
let result = generator.get_fingerprint(&options);
assert!(result.is_ok());
let fp = result.unwrap();
assert!(!fp.fingerprint.navigator.user_agent.is_empty());
assert!(fp.fingerprint.screen.width > 0);
}
#[test]
fn test_config_builder() {
let config = CamoufoxConfigBuilder::new()
.operating_system("windows")
.block_images(false)
.build();
assert!(config.is_ok());
let config = config.unwrap();
assert!(!config.fingerprint_config.is_empty());
assert!(config
.fingerprint_config
.contains_key("navigator.userAgent"));
}
#[test]
fn test_webgl_sampling() {
let result = webgl::sample_webgl("win", None, None);
assert!(result.is_ok());
let webgl_data = result.unwrap();
assert!(!webgl_data.vendor.is_empty());
assert!(!webgl_data.renderer.is_empty());
}
#[test]
fn test_fonts() {
let fonts = fonts::get_fonts_for_os("win");
assert!(!fonts.is_empty());
assert!(fonts.contains(&"Arial".to_string()));
}
#[test]
fn test_env_vars() {
let mut config = std::collections::HashMap::new();
config.insert(
"navigator.userAgent".to_string(),
serde_json::json!("Mozilla/5.0"),
);
let env_vars = env_vars::config_to_env_vars(&config).unwrap();
assert!(!env_vars.is_empty());
assert!(env_vars.contains_key("CAMOU_CONFIG_1"));
}
}
+251
View File
@@ -0,0 +1,251 @@
//! WebGL fingerprint sampling from SQLite database.
//!
//! Samples realistic WebGL configurations based on OS-specific probability distributions.
use rand::Rng;
use rusqlite::{Connection, Result as SqliteResult};
use std::collections::HashMap;
use std::io::Write;
use tempfile::NamedTempFile;
use crate::camoufox::data;
/// WebGL fingerprint data.
#[derive(Debug, Clone)]
pub struct WebGLData {
pub vendor: String,
pub renderer: String,
pub config: HashMap<String, serde_json::Value>,
}
/// Error type for WebGL operations.
#[derive(Debug, thiserror::Error)]
pub enum WebGLError {
#[error("SQLite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("JSON parsing error: {0}")]
Json(#[from] serde_json::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("No WebGL data found for OS: {0}")]
NoDataForOS(String),
#[error("Invalid vendor/renderer combination for OS {os}: {vendor}/{renderer}")]
InvalidCombination {
os: String,
vendor: String,
renderer: String,
},
}
/// Sample a WebGL configuration for the given OS.
///
/// If `vendor` and `renderer` are provided, returns the specific configuration.
/// Otherwise, randomly samples based on OS-specific probability weights.
pub fn sample_webgl(
os: &str,
vendor: Option<&str>,
renderer: Option<&str>,
) -> Result<WebGLData, WebGLError> {
// Write embedded database to a temporary file
let mut temp_file = NamedTempFile::new()?;
temp_file.write_all(data::WEBGL_DATA_DB)?;
let db_path = temp_file.path();
let conn = Connection::open(db_path)?;
// Validate OS
let os_column = match os {
"win" | "windows" => "win",
"mac" | "macos" => "mac",
"lin" | "linux" => "lin",
_ => return Err(WebGLError::NoDataForOS(os.to_string())),
};
if let (Some(v), Some(r)) = (vendor, renderer) {
sample_specific(&conn, os_column, v, r)
} else {
sample_random(&conn, os_column)
}
}
fn sample_specific(
conn: &Connection,
os_column: &str,
vendor: &str,
renderer: &str,
) -> Result<WebGLData, WebGLError> {
let query = format!(
"SELECT vendor, renderer, data, {} FROM webgl_fingerprints WHERE vendor = ?1 AND renderer = ?2",
os_column
);
let mut stmt = conn.prepare(&query)?;
let mut rows = stmt.query([vendor, renderer])?;
if let Some(row) = rows.next()? {
let weight: f64 = row.get(3)?;
if weight <= 0.0 {
return Err(WebGLError::InvalidCombination {
os: os_column.to_string(),
vendor: vendor.to_string(),
renderer: renderer.to_string(),
});
}
let data_json: String = row.get(2)?;
let config: HashMap<String, serde_json::Value> = serde_json::from_str(&data_json)?;
Ok(WebGLData {
vendor: vendor.to_string(),
renderer: renderer.to_string(),
config,
})
} else {
Err(WebGLError::InvalidCombination {
os: os_column.to_string(),
vendor: vendor.to_string(),
renderer: renderer.to_string(),
})
}
}
fn sample_random(conn: &Connection, os_column: &str) -> Result<WebGLData, WebGLError> {
let query = format!(
"SELECT vendor, renderer, data, {} FROM webgl_fingerprints WHERE {} > 0",
os_column, os_column
);
let mut stmt = conn.prepare(&query)?;
let rows: Vec<(String, String, String, f64)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, f64>(3)?,
))
})?
.collect::<SqliteResult<Vec<_>>>()?;
if rows.is_empty() {
return Err(WebGLError::NoDataForOS(os_column.to_string()));
}
// Calculate total weight
let total_weight: f64 = rows.iter().map(|(_, _, _, w)| w).sum();
// Weighted random selection
let mut rng = rand::rng();
let threshold = rng.random::<f64>() * total_weight;
let mut cumulative = 0.0;
for (vendor, renderer, data_json, weight) in &rows {
cumulative += *weight;
if cumulative >= threshold {
let config: HashMap<String, serde_json::Value> = serde_json::from_str(data_json)?;
return Ok(WebGLData {
vendor: vendor.clone(),
renderer: renderer.clone(),
config,
});
}
}
// Fallback to last row
let (vendor, renderer, data_json, _) = rows.last().unwrap();
let config: HashMap<String, serde_json::Value> = serde_json::from_str(data_json)?;
Ok(WebGLData {
vendor: vendor.clone(),
renderer: renderer.clone(),
config,
})
}
/// Get all possible vendor/renderer pairs for each OS.
pub fn get_possible_pairs() -> Result<HashMap<String, Vec<(String, String)>>, WebGLError> {
// Write embedded database to a temporary file
let mut temp_file = NamedTempFile::new()?;
temp_file.write_all(data::WEBGL_DATA_DB)?;
let db_path = temp_file.path();
let conn = Connection::open(db_path)?;
let mut result = HashMap::new();
for os in &["win", "mac", "lin"] {
let query = format!(
"SELECT DISTINCT vendor, renderer FROM webgl_fingerprints WHERE {} > 0 ORDER BY {} DESC",
os, os
);
let mut stmt = conn.prepare(&query)?;
let pairs: Vec<(String, String)> = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<SqliteResult<Vec<_>>>()?;
result.insert(os.to_string(), pairs);
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sample_webgl_windows() {
let result = sample_webgl("win", None, None);
assert!(
result.is_ok(),
"Failed to sample WebGL for Windows: {:?}",
result.err()
);
let data = result.unwrap();
assert!(!data.vendor.is_empty());
assert!(!data.renderer.is_empty());
assert!(!data.config.is_empty());
}
#[test]
fn test_sample_webgl_macos() {
let result = sample_webgl("mac", None, None);
assert!(
result.is_ok(),
"Failed to sample WebGL for macOS: {:?}",
result.err()
);
}
#[test]
fn test_sample_webgl_linux() {
let result = sample_webgl("lin", None, None);
assert!(
result.is_ok(),
"Failed to sample WebGL for Linux: {:?}",
result.err()
);
}
#[test]
fn test_get_possible_pairs() {
let result = get_possible_pairs();
assert!(
result.is_ok(),
"Failed to get possible pairs: {:?}",
result.err()
);
let pairs = result.unwrap();
assert!(pairs.contains_key("win"));
assert!(pairs.contains_key("mac"));
assert!(pairs.contains_key("lin"));
assert!(!pairs.get("win").unwrap().is_empty());
}
}
+190 -166
View File
@@ -1,12 +1,14 @@
use crate::browser_runner::BrowserRunner;
use crate::camoufox::{CamoufoxConfigBuilder, GeoIPOption, ScreenConstraints};
use crate::profile::BrowserProfile;
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use tauri::AppHandle;
use tauri_plugin_shell::ShellExt;
use tokio::process::Command as TokioCommand;
use tokio::sync::Mutex as AsyncMutex;
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -86,7 +88,7 @@ impl CamoufoxManager {
}
pub fn instance() -> &'static CamoufoxManager {
&CAMOUFOX_NODECAR_LAUNCHER
&CAMOUFOX_LAUNCHER
}
pub fn get_profiles_dir(&self) -> PathBuf {
@@ -103,112 +105,91 @@ impl CamoufoxManager {
/// Generate Camoufox fingerprint configuration during profile creation
pub async fn generate_fingerprint_config(
&self,
app_handle: &AppHandle,
_app_handle: &AppHandle,
profile: &crate::profile::BrowserProfile,
config: &CamoufoxConfig,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let mut config_args = vec!["camoufox".to_string(), "generate-config".to_string()];
// Always ensure executable_path is set to the user's binary location
// Get executable path
let executable_path = if let Some(path) = &config.executable_path {
path.clone()
PathBuf::from(path)
} else {
// Use the browser runner helper with the real profile
// Use self.browser_runner instead of instance()
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
.to_string_lossy()
.to_string()
};
config_args.extend(["--executable-path".to_string(), executable_path]);
// Pass existing fingerprint if provided (for advanced form partial fingerprints)
if let Some(fingerprint) = &config.fingerprint {
config_args.extend(["--fingerprint".to_string(), fingerprint.clone()]);
}
// Build the config using CamoufoxConfigBuilder
let mut builder = CamoufoxConfigBuilder::new()
.block_images(config.block_images.unwrap_or(false))
.block_webrtc(config.block_webrtc.unwrap_or(false))
.block_webgl(config.block_webgl.unwrap_or(false));
if let Some(serde_json::Value::Bool(true)) = &config.geoip {
config_args.push("--geoip".to_string());
}
// Add proxy if provided (can be passed directly during fingerprint generation)
if let Some(proxy) = &config.proxy {
config_args.extend(["--proxy".to_string(), proxy.clone()]);
}
// Add screen dimensions if provided
if let Some(max_width) = config.screen_max_width {
config_args.extend(["--max-width".to_string(), max_width.to_string()]);
}
if let Some(max_height) = config.screen_max_height {
config_args.extend(["--max-height".to_string(), max_height.to_string()]);
}
if let Some(min_width) = config.screen_min_width {
config_args.extend(["--min-width".to_string(), min_width.to_string()]);
}
if let Some(min_height) = config.screen_min_height {
config_args.extend(["--min-height".to_string(), min_height.to_string()]);
}
// Add block_* options
if let Some(block_images) = config.block_images {
if block_images {
config_args.push("--block-images".to_string());
}
}
if let Some(block_webrtc) = config.block_webrtc {
if block_webrtc {
config_args.push("--block-webrtc".to_string());
}
}
if let Some(block_webgl) = config.block_webgl {
if block_webgl {
config_args.push("--block-webgl".to_string());
}
}
// Add OS option for fingerprint generation
// Set operating system
if let Some(os) = &config.os {
config_args.extend(["--os".to_string(), os.clone()]);
builder = builder.operating_system(os);
}
// Execute config generation command
let mut config_sidecar = self.get_nodecar_sidecar(app_handle)?;
for arg in &config_args {
config_sidecar = config_sidecar.arg(arg);
// Build screen constraints if provided
if config.screen_min_width.is_some()
|| config.screen_max_width.is_some()
|| config.screen_min_height.is_some()
|| config.screen_max_height.is_some()
{
let screen_constraints = ScreenConstraints {
min_width: config.screen_min_width,
max_width: config.screen_max_width,
min_height: config.screen_min_height,
max_height: config.screen_max_height,
};
builder = builder.screen_constraints(screen_constraints);
}
let config_output = config_sidecar.output().await?;
if !config_output.status.success() {
let stderr = String::from_utf8_lossy(&config_output.stderr);
return Err(format!("Failed to generate camoufox fingerprint config: {stderr}").into());
// Parse proxy if provided
if let Some(proxy_str) = &config.proxy {
let proxy_config = crate::camoufox::ProxyConfig::from_url(proxy_str)
.map_err(|e| format!("Failed to parse proxy URL: {e}"))?;
builder = builder.proxy(proxy_config);
}
Ok(String::from_utf8_lossy(&config_output.stdout).to_string())
// Set Firefox version from executable
if let Some(version) = crate::camoufox::config::get_firefox_version(&executable_path) {
builder = builder.ff_version(version);
}
// Handle geoip option
if let Some(geoip_value) = &config.geoip {
match geoip_value {
serde_json::Value::Bool(true) => {
// Auto-detect IP (through proxy if set)
builder = builder.geoip(GeoIPOption::Auto);
}
serde_json::Value::String(ip) => {
// Use specific IP
builder = builder.geoip(GeoIPOption::IP(ip.clone()));
}
_ => {
// geoip: false or other values - don't apply geolocation
}
}
}
// Build the config (async to handle geoip)
let launch_config = builder
.build_async()
.await
.map_err(|e| format!("Failed to build Camoufox config: {e}"))?;
// Return the fingerprint config as JSON
let config_json = serde_json::to_string(&launch_config.fingerprint_config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
Ok(config_json)
}
/// Get the nodecar sidecar command
fn get_nodecar_sidecar(
&self,
app_handle: &AppHandle,
) -> Result<tauri_plugin_shell::process::Command, Box<dyn std::error::Error + Send + Sync>> {
let shell = app_handle.shell();
let sidecar_command = shell
.sidecar("nodecar")
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?;
Ok(sidecar_command)
}
/// Launch Camoufox browser using nodecar sidecar
/// Launch Camoufox browser by directly spawning the process
pub async fn launch_camoufox(
&self,
app_handle: &AppHandle,
_app_handle: &AppHandle,
profile: &crate::profile::BrowserProfile,
profile_path: &str,
config: &CamoufoxConfig,
@@ -221,44 +202,39 @@ impl CamoufoxManager {
return Err("No fingerprint provided".into());
};
// Always ensure executable_path is set to the user's binary location
// Get executable path
let executable_path = if let Some(path) = &config.executable_path {
path.clone()
PathBuf::from(path)
} else {
// Use the browser runner helper with the real profile
// Use self.browser_runner instead of instance()
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
.to_string_lossy()
.to_string()
};
// Build nodecar command arguments
let mut args = vec!["camoufox".to_string(), "start".to_string()];
// Parse the fingerprint config JSON
let fingerprint_config: HashMap<String, serde_json::Value> =
serde_json::from_str(&custom_config)
.map_err(|e| format!("Failed to parse fingerprint config: {e}"))?;
// Add profile path - ensure it's an absolute path
let absolute_profile_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf())
.to_string_lossy()
.to_string();
args.extend(["--profile-path".to_string(), absolute_profile_path]);
// Convert to environment variables using CAMOU_CONFIG chunking
let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config)
.map_err(|e| format!("Failed to convert config to env vars: {e}"))?;
// Build command arguments
let mut args = vec![
"-profile".to_string(),
std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf())
.to_string_lossy()
.to_string(),
"-no-remote".to_string(),
];
// Add URL if provided
if let Some(url) = url {
args.extend(["--url".to_string(), url.to_string()]);
}
// Always add the executable path
args.extend(["--executable-path".to_string(), executable_path]);
// Always add the generated custom config
args.extend(["--custom-config".to_string(), custom_config]);
// Add proxy if provided
if let Some(proxy) = &config.proxy {
args.extend(["--proxy".to_string(), proxy.clone()]);
args.push("-new-tab".to_string());
args.push(url.to_string());
}
// Add headless flag for tests
@@ -266,43 +242,62 @@ impl CamoufoxManager {
args.push("--headless".to_string());
}
// Get the nodecar sidecar command
let mut sidecar_command = self.get_nodecar_sidecar(app_handle)?;
log::info!(
"Launching Camoufox: {:?} with args: {:?}",
executable_path,
args
);
// Add all arguments to the sidecar command
for arg in &args {
sidecar_command = sidecar_command.arg(arg);
// Spawn the browser process
let mut command = TokioCommand::new(&executable_path);
command
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
// Add environment variables
for (key, value) in &env_vars {
command.env(key, value);
}
// Execute nodecar sidecar command
log::info!("Executing nodecar command with args: {args:?}");
let output = sidecar_command.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
log::info!("nodecar camoufox failed - stdout: {stdout}, stderr: {stderr}");
return Err(format!("nodecar camoufox failed: {stderr}").into());
// Handle fontconfig on Linux
if cfg!(target_os = "linux") {
let target_os = config.os.as_deref().unwrap_or("linux");
if let Some(fontconfig_path) =
crate::camoufox::env_vars::get_fontconfig_env(target_os, &executable_path)
{
command.env("FONTCONFIG_PATH", fontconfig_path);
}
}
let stdout = String::from_utf8_lossy(&output.stdout);
log::info!("nodecar camoufox output: {stdout}");
let child = command
.spawn()
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
// Parse the JSON output
let launch_result: CamoufoxLaunchResult = serde_json::from_str(&stdout)
.map_err(|e| format!("Failed to parse nodecar output as JSON: {e}\nOutput was: {stdout}"))?;
let process_id = child.id();
let instance_id = format!("camoufox_{}", process_id.unwrap_or(0));
log::info!("Camoufox launched with PID: {:?}", process_id);
// Store the instance
let instance = CamoufoxInstance {
id: launch_result.id.clone(),
process_id: launch_result.processId,
profile_path: launch_result.profilePath.clone(),
url: launch_result.url.clone(),
id: instance_id.clone(),
process_id,
profile_path: Some(profile_path.to_string()),
url: url.map(String::from),
};
let launch_result = CamoufoxLaunchResult {
id: instance_id.clone(),
processId: process_id,
profilePath: Some(profile_path.to_string()),
url: url.map(String::from),
};
{
let mut inner = self.inner.lock().await;
inner.instances.insert(launch_result.id.clone(), instance);
inner.instances.insert(instance_id, instance);
}
Ok(launch_result)
@@ -311,41 +306,70 @@ impl CamoufoxManager {
/// Stop a Camoufox process by ID
pub async fn stop_camoufox(
&self,
app_handle: &AppHandle,
_app_handle: &AppHandle,
id: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
// Get the nodecar sidecar command
let sidecar_command = self
.get_nodecar_sidecar(app_handle)?
.arg("camoufox")
.arg("stop")
.arg("--id")
.arg(id);
// Get the process ID from our tracking
let process_id = {
let inner = self.inner.lock().await;
inner
.instances
.get(id)
.and_then(|instance| instance.process_id)
};
// Execute nodecar stop command
let output = sidecar_command.output().await?;
if let Some(pid) = process_id {
// Kill the process
let success = self.kill_process(pid);
if !output.status.success() {
let _stderr = String::from_utf8_lossy(&output.stderr);
return Ok(false);
}
if success {
// Remove from our tracking
let mut inner = self.inner.lock().await;
inner.instances.remove(id);
log::info!("Stopped Camoufox instance {} (PID: {})", id, pid);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout)
.map_err(|e| format!("Failed to parse nodecar stop output: {e}"))?;
let success = result
.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if success {
// Remove from our tracking
Ok(success)
} else {
// No process ID found, just remove from tracking
let mut inner = self.inner.lock().await;
inner.instances.remove(id);
Ok(true)
}
}
/// Kill a process by PID
fn kill_process(&self, pid: u32) -> bool {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
let result = std::process::Command::new("kill")
.args(["-TERM", &pid.to_string()])
.status();
match result {
Ok(status) => status.success() || status.signal() == Some(0),
Err(e) => {
log::warn!("Failed to kill process {}: {}", pid, e);
false
}
}
}
Ok(success)
#[cfg(windows)]
{
let result = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/T"])
.status();
match result {
Ok(status) => status.success(),
Err(e) => {
log::warn!("Failed to kill process {}: {}", pid, e);
false
}
}
}
}
/// Find Camoufox server by profile path (for integration with browser_runner)
@@ -544,7 +568,7 @@ impl CamoufoxManager {
}
impl CamoufoxManager {
pub async fn launch_camoufox_profile_nodecar(
pub async fn launch_camoufox_profile(
&self,
app_handle: AppHandle,
profile: BrowserProfile,
@@ -574,7 +598,7 @@ impl CamoufoxManager {
url.as_deref(),
)
.await
.map_err(|e| format!("Failed to launch Camoufox via nodecar: {e}"))
.map_err(|e| format!("Failed to launch Camoufox: {e}"))
}
}
@@ -597,5 +621,5 @@ mod tests {
// Global singleton instance
lazy_static::lazy_static! {
static ref CAMOUFOX_NODECAR_LAUNCHER: CamoufoxManager = CamoufoxManager::new();
static ref CAMOUFOX_LAUNCHER: CamoufoxManager = CamoufoxManager::new();
}
+54 -1
View File
@@ -350,6 +350,50 @@ impl Downloader {
}
}
/// Ensure version.json exists in the Camoufox installation directory.
/// Creates the file if it doesn't exist, using the version from the tag name.
async fn ensure_camoufox_version_json(
&self,
browser_dir: &Path,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// The browser_dir is typically: binaries/camoufox/<version>/
// Find the executable directory within it
let version_json_locations = vec![
browser_dir.join("version.json"),
browser_dir.join("camoufox").join("version.json"),
];
// Check if version.json already exists in any expected location
for location in &version_json_locations {
if location.exists() {
log::info!("version.json already exists at: {}", location.display());
return Ok(());
}
}
// Parse the Firefox version from the Camoufox version tag
// Format: "135.0.1-beta.24" -> Firefox version is "135.0.1" (or just "135.0")
let firefox_version = version.split('-').next().unwrap_or(version);
// Create version.json in the browser directory
let version_json_path = browser_dir.join("version.json");
let version_data = serde_json::json!({
"version": firefox_version
});
let version_json_str = serde_json::to_string_pretty(&version_data)?;
tokio::fs::write(&version_json_path, version_json_str).await?;
log::info!(
"Created version.json at {} with Firefox version: {}",
version_json_path.display(),
firefox_version
);
Ok(())
}
pub async fn download_browser<R: tauri::Runtime>(
&self,
app_handle: &tauri::AppHandle<R>,
@@ -809,7 +853,7 @@ impl Downloader {
}
}
// If this is Camoufox, automatically download GeoIP database
// If this is Camoufox, automatically download GeoIP database and create version.json
if browser_str == "camoufox" {
// Check if GeoIP database is already available
if !crate::geoip_downloader::GeoIPDownloader::is_geoip_database_available() {
@@ -831,6 +875,15 @@ impl Downloader {
} else {
log::info!("GeoIP database already available");
}
// Create version.json if it doesn't exist
if let Err(e) = self
.ensure_camoufox_version_json(&browser_dir, &version)
.await
{
log::warn!("Failed to create version.json for Camoufox: {e}");
// Don't fail the download if version.json creation fails
}
}
// Emit completion
+1 -30
View File
@@ -15,6 +15,7 @@ mod auto_updater;
mod browser;
mod browser_runner;
mod browser_version_manager;
pub mod camoufox;
mod camoufox_manager;
mod default_browser;
mod downloaded_browsers_registry;
@@ -143,35 +144,6 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
}
}
#[tauri::command]
async fn warm_up_nodecar(app: tauri::AppHandle) -> Result<(), String> {
use tauri_plugin_shell::ShellExt;
use tokio::time::{timeout, Duration};
let start_time = std::time::Instant::now();
// Use sidecar to execute a fast, harmless command that ensures the binary is loaded
let cmd = app
.shell()
.sidecar("nodecar")
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
.arg("help");
let exec_future = async { cmd.output().await };
match timeout(Duration::from_secs(120), exec_future).await {
Ok(Ok(_output)) => {
let duration = start_time.elapsed();
log::info!(
"Nodecar warm-up (frontend-triggered) completed in {:.2}s",
duration.as_secs_f64()
);
Ok(())
}
Ok(Err(e)) => Err(format!("Failed to execute nodecar for warm-up: {e}")),
Err(_) => Err("Nodecar warm-up timed out after 120s".to_string()),
}
}
#[tauri::command]
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
log::info!("handle_url_open called with URL: {url}");
@@ -838,7 +810,6 @@ pub fn run() {
delete_selected_profiles,
is_geoip_database_available,
download_geoip_database,
warm_up_nodecar,
start_api_server,
stop_api_server,
get_api_server_status,
+6 -8
View File
@@ -820,11 +820,9 @@ impl ProfileManager {
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
// Handle Camoufox profiles using nodecar-based status checking
// Handle Camoufox profiles using CamoufoxManager-based status checking
if profile.browser == "camoufox" {
return self
.check_camoufox_status_via_nodecar(&app_handle, profile)
.await;
return self.check_camoufox_status(&app_handle, profile).await;
}
// For non-camoufox browsers, use the existing PID-based logic
@@ -888,7 +886,7 @@ impl ProfileManager {
"zen" => exe_name.contains("zen"),
"chromium" => exe_name.contains("chromium"),
"brave" => exe_name.contains("brave"),
// Camoufox is handled via nodecar, not PID-based checking
// Camoufox is handled via CamoufoxManager, not PID-based checking
_ => false,
};
@@ -981,8 +979,8 @@ impl ProfileManager {
Ok(is_running)
}
// Check Camoufox status using nodecar-based approach
async fn check_camoufox_status_via_nodecar(
// Check Camoufox status using CamoufoxManager
async fn check_camoufox_status(
&self,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
@@ -1062,7 +1060,7 @@ impl ProfileManager {
}
Err(e) => {
// Error checking status, assume not running and clear process ID
log::warn!("Warning: Failed to check Camoufox status via nodecar: {e}");
log::warn!("Warning: Failed to check Camoufox status: {e}");
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
let metadata_file = profile_uuid_dir.join("metadata.json");