feat: fully implement happy flow for persistant fingerprint generation

This commit is contained in:
zhom
2025-08-06 04:33:01 +04:00
parent ff35717cb5
commit b5b08a0196
20 changed files with 2531 additions and 1545 deletions
+1
View File
@@ -26,6 +26,7 @@
"camoufox-js": "^0.6.2",
"commander": "^14.0.0",
"dotenv": "^17.2.1",
"fingerprint-generator": "^2.1.69",
"get-port": "^7.1.0",
"nodemon": "^3.1.10",
"playwright-core": "^1.54.2",
+293
View File
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { launchOptions } from "camoufox-js";
import type { LaunchOptions } from "camoufox-js/dist/utils.js";
import {
type CamoufoxConfig,
@@ -10,6 +11,194 @@ import {
saveCamoufoxConfig,
} from "./camoufox-storage.js";
/**
* Convert fingerprint-generator format to camoufox fingerprint format (reverse of convertCamoufoxToFingerprintGenerator)
* @param fingerprintObj The fingerprint-generator object
* @returns camoufox fingerprint object
*/
export function convertFingerprintGeneratorToCamoufox(
fingerprintObj: Record<string, any>,
): Record<string, any> {
const camoufoxData: Record<string, any> = {};
// Reverse mappings from fingerprint-generator structure to camoufox keys
const reverseMappings: Record<string, string> = {
// Navigator properties
"navigator.userAgent": "navigator.userAgent",
"navigator.platform": "navigator.platform",
"navigator.hardwareConcurrency": "navigator.hardwareConcurrency",
"navigator.maxTouchPoints": "navigator.maxTouchPoints",
"navigator.doNotTrack": "navigator.doNotTrack",
"navigator.appCodeName": "navigator.appCodeName",
"navigator.appName": "navigator.appName",
"navigator.appVersion": "navigator.appVersion",
"navigator.oscpu": "navigator.oscpu",
"navigator.product": "navigator.product",
"navigator.language": "navigator.language",
"navigator.languages": "navigator.languages",
"navigator.globalPrivacyControl": "navigator.globalPrivacyControl",
// Screen properties
"screen.width": "screen.width",
"screen.height": "screen.height",
"screen.availWidth": "screen.availWidth",
"screen.availHeight": "screen.availHeight",
"screen.availTop": "screen.availTop",
"screen.availLeft": "screen.availLeft",
"screen.colorDepth": "screen.colorDepth",
"screen.pixelDepth": "screen.pixelDepth",
"screen.outerWidth": "window.outerWidth",
"screen.outerHeight": "window.outerHeight",
"screen.innerWidth": "window.innerWidth",
"screen.innerHeight": "window.innerHeight",
"screen.screenX": "window.screenX",
"screen.screenY": "window.screenY",
"screen.pageXOffset": "screen.pageXOffset",
"screen.pageYOffset": "screen.pageYOffset",
"screen.devicePixelRatio": "window.devicePixelRatio",
"screen.clientWidth": "document.body.clientWidth",
"screen.clientHeight": "document.body.clientHeight",
// WebGL properties
"videoCard.vendor": "webGl:vendor",
"videoCard.renderer": "webGl:renderer",
// Headers
"headers.Accept-Encoding": "headers.Accept-Encoding",
// Battery
"battery.charging": "battery:charging",
"battery.chargingTime": "battery:chargingTime",
"battery.dischargingTime": "battery:dischargingTime",
};
// Apply reverse mappings
for (const [fingerprintPath, camoufoxKey] of Object.entries(
reverseMappings,
)) {
const pathParts = fingerprintPath.split(".");
let current = fingerprintObj;
// Navigate to the nested property
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
if (!current[part]) {
break;
}
current = current[part];
}
// Get the final value
const finalKey = pathParts[pathParts.length - 1];
if (current && current[finalKey] !== undefined) {
camoufoxData[camoufoxKey] = current[finalKey];
}
}
// Handle fonts separately
if (fingerprintObj.fonts && Array.isArray(fingerprintObj.fonts)) {
camoufoxData.fonts = fingerprintObj.fonts;
}
return camoufoxData;
}
/**
* Convert camoufox fingerprint format to fingerprint-generator format
* @param camoufoxFingerprint The camoufox fingerprint object
* @returns fingerprint-generator object
*/
function convertCamoufoxToFingerprintGenerator(
camoufoxFingerprint: Record<string, any>,
): any {
const fingerprintObj: Record<string, any> = {
navigator: {},
screen: {},
videoCard: {},
headers: {},
battery: {},
};
// Mapping from camoufox keys to fingerprint-generator structure based on the YAML
const mappings: Record<string, string> = {
// Navigator properties
"navigator.userAgent": "navigator.userAgent",
"navigator.platform": "navigator.platform",
"navigator.hardwareConcurrency": "navigator.hardwareConcurrency",
"navigator.maxTouchPoints": "navigator.maxTouchPoints",
"navigator.doNotTrack": "navigator.doNotTrack",
"navigator.appCodeName": "navigator.appCodeName",
"navigator.appName": "navigator.appName",
"navigator.appVersion": "navigator.appVersion",
"navigator.oscpu": "navigator.oscpu",
"navigator.product": "navigator.product",
"navigator.language": "navigator.language",
"navigator.languages": "navigator.languages",
"navigator.globalPrivacyControl": "navigator.globalPrivacyControl",
// Screen properties
"screen.width": "screen.width",
"screen.height": "screen.height",
"screen.availWidth": "screen.availWidth",
"screen.availHeight": "screen.availHeight",
"screen.availTop": "screen.availTop",
"screen.availLeft": "screen.availLeft",
"screen.colorDepth": "screen.colorDepth",
"screen.pixelDepth": "screen.pixelDepth",
"window.outerWidth": "screen.outerWidth",
"window.outerHeight": "screen.outerHeight",
"window.innerWidth": "screen.innerWidth",
"window.innerHeight": "screen.innerHeight",
"window.screenX": "screen.screenX",
"window.screenY": "screen.screenY",
"screen.pageXOffset": "screen.pageXOffset",
"screen.pageYOffset": "screen.pageYOffset",
"window.devicePixelRatio": "screen.devicePixelRatio",
"document.body.clientWidth": "screen.clientWidth",
"document.body.clientHeight": "screen.clientHeight",
// WebGL properties
"webGl:vendor": "videoCard.vendor",
"webGl:renderer": "videoCard.renderer",
// Headers
"headers.Accept-Encoding": "headers.Accept-Encoding",
// Battery
"battery:charging": "battery.charging",
"battery:chargingTime": "battery.chargingTime",
"battery:dischargingTime": "battery.dischargingTime",
};
// Apply mappings
for (const [camoufoxKey, fingerprintPath] of Object.entries(mappings)) {
if (camoufoxFingerprint[camoufoxKey] !== undefined) {
const pathParts = fingerprintPath.split(".");
let current = fingerprintObj;
// Navigate to the nested property, creating objects as needed
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
// Set the final value
const finalKey = pathParts[pathParts.length - 1];
current[finalKey] = camoufoxFingerprint[camoufoxKey];
}
}
// Handle fonts separately
if (camoufoxFingerprint.fonts && Array.isArray(camoufoxFingerprint.fonts)) {
fingerprintObj.fonts = camoufoxFingerprint.fonts;
}
return fingerprintObj;
}
/**
* Start a Camoufox instance in a separate process
* @param options Camoufox launch options
@@ -21,6 +210,7 @@ export async function startCamoufoxProcess(
options: LaunchOptions = {},
profilePath?: string,
url?: string,
customConfig?: string,
): Promise<CamoufoxConfig> {
// Generate a unique ID for this instance
const id = generateCamoufoxId();
@@ -31,6 +221,7 @@ export async function startCamoufoxProcess(
options,
profilePath,
url,
customConfig,
};
// Save the configuration before starting the process
@@ -252,3 +443,105 @@ export async function stopAllCamoufoxProcesses(): Promise<void> {
const stopPromises = configs.map((config) => stopCamoufoxProcess(config.id));
await Promise.all(stopPromises);
}
interface GenerateConfigOptions {
proxy?: string;
maxWidth?: number;
maxHeight?: number;
geoip?: string | boolean;
blockImages?: boolean;
blockWebrtc?: boolean;
blockWebgl?: boolean;
executablePath?: string;
fingerprint?: string;
}
/**
* Generate Camoufox configuration using launchOptions
* @param options Configuration options
* @returns Promise resolving to the generated config JSON string
*/
export async function generateCamoufoxConfig(
options: GenerateConfigOptions,
): Promise<string> {
try {
// Build launch options
const launchOpts: LaunchOptions = {
// Always set these defaults
headless: false,
i_know_what_im_doing: true,
config: {
disableTheming: true,
showcursor: false,
},
};
// Always set geoip and blocking options
launchOpts.geoip = options.geoip !== undefined ? options.geoip : true;
if (options.blockImages) {
launchOpts.block_images = true;
}
if (options.blockWebrtc) {
launchOpts.block_webrtc = true;
}
if (options.blockWebgl) {
launchOpts.block_webgl = true;
}
if (options.executablePath) {
launchOpts.executable_path = options.executablePath;
}
// If fingerprint is provided, use it and ignore other options except executable_path and block_*
if (options.fingerprint) {
try {
const camoufoxFingerprint = JSON.parse(options.fingerprint);
// Convert camoufox fingerprint format to fingerprint-generator format
const fingerprintObj =
convertCamoufoxToFingerprintGenerator(camoufoxFingerprint);
launchOpts.fingerprint = fingerprintObj;
} catch (error) {
throw new Error(`Invalid fingerprint JSON: ${error}`);
}
} else {
// Use individual options to build configuration
if (options.proxy) {
launchOpts.proxy = options.proxy;
}
if (options.maxWidth && options.maxHeight) {
launchOpts.screen = {
maxWidth: options.maxWidth,
maxHeight: options.maxHeight,
};
}
}
// Generate the configuration using launchOptions
const generatedOptions = await launchOptions(launchOpts);
// Extract the environment variables that contain the config
const envVars = generatedOptions.env || {};
// Reconstruct the config from environment variables using getEnvVars utility
let configStr = "";
let chunkIndex = 1;
while (envVars[`CAMOU_CONFIG_${chunkIndex}`]) {
configStr += envVars[`CAMOU_CONFIG_${chunkIndex}`];
chunkIndex++;
}
if (!configStr) {
throw new Error("No configuration generated");
}
// Parse and return the config as JSON string
const config = JSON.parse(configStr);
return JSON.stringify(config);
} catch (error) {
throw new Error(`Failed to generate Camoufox config: ${error}`);
}
}
+1
View File
@@ -9,6 +9,7 @@ export interface CamoufoxConfig {
profilePath?: string;
url?: string;
processId?: number;
customConfig?: string; // JSON string of the fingerprint config
}
const STORAGE_DIR = path.join(tmp.tmpdir, "donutbrowser", "camoufox");
+55 -49
View File
@@ -1,6 +1,8 @@
import { launchServer } from "camoufox-js";
import { launchOptions } from "camoufox-js";
import type { LaunchOptions } from "camoufox-js/dist/utils.js";
import { type Browser, type BrowserServer, firefox } from "playwright-core";
import { getCamoufoxConfig, saveCamoufoxConfig } from "./camoufox-storage.js";
import { getEnvVars } from "./utils.js";
/**
* Run a Camoufox browser server as a worker process
@@ -73,62 +75,67 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
try {
// Prepare options for Camoufox
const camoufoxOptions = { ...config.options };
const camoufoxOptions: LaunchOptions = { ...config.options };
// Add profile path if provided
if (config.profilePath) {
camoufoxOptions.user_data_dir = config.profilePath;
}
// Theming
camoufoxOptions.disableTheming = true;
camoufoxOptions.showcursor = false;
// Set Firefox preferences for theming
if (!camoufoxOptions.firefox_user_prefs) {
camoufoxOptions.firefox_user_prefs = {};
if (camoufoxOptions.block_images) {
camoufoxOptions.block_images = true;
}
// Default to non-headless for visibility
if (camoufoxOptions.headless === undefined) {
camoufoxOptions.headless = false;
if (camoufoxOptions.block_webgl) {
camoufoxOptions.block_webgl = true;
}
// Launch the server with proper options
server = await launchServer({
ws_path: `/ws_${config.id}`,
os: camoufoxOptions.os,
block_images: camoufoxOptions.block_images,
block_webrtc: camoufoxOptions.block_webrtc,
block_webgl: camoufoxOptions.block_webgl,
disable_coop: camoufoxOptions.disable_coop,
geoip: camoufoxOptions.geoip,
humanize: camoufoxOptions.humanize,
locale: camoufoxOptions.locale,
addons: camoufoxOptions.addons,
fonts: camoufoxOptions.fonts,
custom_fonts_only: camoufoxOptions.custom_fonts_only,
exclude_addons: camoufoxOptions.exclude_addons,
screen: camoufoxOptions.screen,
window: camoufoxOptions.window,
fingerprint: camoufoxOptions.fingerprint,
ff_version: camoufoxOptions.ff_version,
headless: camoufoxOptions.headless,
main_world_eval: camoufoxOptions.main_world_eval,
executable_path: camoufoxOptions.executable_path,
firefox_user_prefs: camoufoxOptions.firefox_user_prefs,
proxy: camoufoxOptions.proxy,
enable_cache: camoufoxOptions.enable_cache,
args: camoufoxOptions.args,
env: camoufoxOptions.env,
debug: camoufoxOptions.debug,
virtual_display: camoufoxOptions.virtual_display,
webgl_config: camoufoxOptions.webgl_config,
config: {
disableTheming: true,
showcursor: false,
timezone: camoufoxOptions.timezone,
},
if (camoufoxOptions.block_webrtc) {
camoufoxOptions.block_webrtc = true;
}
// Check for headless mode from config (no environment variable check)
if (camoufoxOptions.headless) {
camoufoxOptions.headless = true;
}
// Always set these defaults
camoufoxOptions.i_know_what_im_doing = true;
camoufoxOptions.config = {
disableTheming: true,
showcursor: false,
...(camoufoxOptions.config || {}),
};
// Generate the configuration using launchOptions
const generatedOptions = await launchOptions(camoufoxOptions);
// If we have a custom config from Rust, use it directly as environment variables
let finalEnv = generatedOptions.env || {};
if (config.customConfig) {
try {
// Parse the custom config JSON string
const customConfigObj = JSON.parse(config.customConfig);
// Convert custom config to environment variables using getEnvVars
const customEnvVars = getEnvVars(customConfigObj);
// Merge custom config with generated config (custom takes precedence)
finalEnv = { ...finalEnv, ...customEnvVars };
} catch (error) {
console.error(
"Failed to parse custom config, using generated config:",
error,
);
}
}
// Launch the server with the final configuration
server = await firefox.launchServer({
...generatedOptions,
wsPath: `/ws_${config.id}`,
env: finalEnv,
});
// Connect to the server
@@ -140,8 +147,7 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
saveCamoufoxConfig(config);
// Monitor for window closure to handle Command+Q properly
// Monitor for window closure
const startWindowMonitoring = () => {
windowCheckInterval = setInterval(async () => {
try {
+87 -77
View File
@@ -1,6 +1,7 @@
import type { LaunchOptions } from "camoufox-js/dist/utils.js";
import { program } from "commander";
import {
generateCamoufoxConfig,
startCamoufoxProcess,
stopAllCamoufoxProcesses,
stopCamoufoxProcess,
@@ -152,88 +153,26 @@ program
// Command for Camoufox management
program
.command("camoufox")
.argument("<action>", "start, stop, or list Camoufox instances")
.argument(
"<action>",
"start, stop, list, or generate-config Camoufox instances",
)
.option("--id <id>", "Camoufox ID for stop command")
.option("--profile-path <path>", "profile directory path")
.option("--url <url>", "URL to open")
// Operating system fingerprinting
.option(
"--os <os>",
"OS to emulate (windows, macos, linux, or comma-separated list)",
)
// Blocking options
.option("--block-images", "block all images")
.option("--block-webrtc", "block WebRTC entirely")
// Config generation options
.option("--proxy <proxy>", "proxy URL for config generation")
.option("--max-width <width>", "maximum screen width", parseInt)
.option("--max-height <height>", "maximum screen height", parseInt)
.option("--geoip [ip]", "enable geoip or specify IP")
.option("--block-images", "block images")
.option("--block-webrtc", "block WebRTC")
.option("--block-webgl", "block WebGL")
// Security options
.option("--disable-coop", "disable Cross-Origin-Opener-Policy")
// Geolocation and IP
.option(
"--geoip <ip>",
"IP address for geolocation spoofing (or 'auto' for automatic)",
)
.option("--country <country>", "country code for geolocation")
.option("--timezone <timezone>", "timezone to spoof")
.option("--latitude <lat>", "latitude for geolocation", parseFloat)
.option("--longitude <lng>", "longitude for geolocation", parseFloat)
// UI and behavior
.option(
"--humanize [duration]",
"humanize cursor movement (optional max duration in seconds)",
(val) => (val ? parseFloat(val) : true),
)
.option("--executable-path <path>", "executable path")
.option("--fingerprint <json>", "fingerprint JSON string")
.option("--headless", "run in headless mode")
// Localization
.option("--locale <locale>", "locale(s) to use (comma-separated)")
// Extensions and fonts
.option("--addons <addons>", "Firefox addons to load (comma-separated paths)")
.option("--fonts <fonts>", "additional fonts to load (comma-separated)")
.option("--custom-fonts-only", "use only custom fonts, exclude OS fonts")
.option(
"--exclude-addons <addons>",
"default addons to exclude (comma-separated)",
)
// Screen and window
.option("--screen-min-width <width>", "minimum screen width", parseInt)
.option("--screen-max-width <width>", "maximum screen width", parseInt)
.option("--screen-min-height <height>", "minimum screen height", parseInt)
.option("--screen-max-height <height>", "maximum screen height", parseInt)
.option("--window-width <width>", "fixed window width", parseInt)
.option("--window-height <height>", "fixed window height", parseInt)
// Advanced options
.option("--ff-version <version>", "Firefox version to emulate", parseInt)
.option("--main-world-eval", "enable main world script evaluation")
.option("--webgl-vendor <vendor>", "WebGL vendor string")
.option("--webgl-renderer <renderer>", "WebGL renderer string")
// Proxy
.option(
"--proxy <proxy>",
"proxy URL (protocol://[username:password@]host:port)",
)
// Cache and performance
.option("--disable-cache", "disable browser cache (cache enabled by default)")
// Environment and debugging
.option("--virtual-display <display>", "virtual display number (e.g., :99)")
.option("--debug", "enable debug output")
.option("--args <args>", "additional browser arguments (comma-separated)")
.option("--env <env>", "environment variables (JSON string)")
// Firefox preferences
.option("--firefox-prefs <prefs>", "Firefox user preferences (JSON string)")
// Note: theming and cursor options are hardcoded and not user-configurable
.option("--custom-config <json>", "custom config JSON string")
.description("manage Camoufox browser instances")
.action(
@@ -349,6 +288,11 @@ program
if (options.virtualDisplay)
camoufoxOptions.virtual_display = options.virtualDisplay as string;
if (options.debug) camoufoxOptions.debug = true;
// Handle headless mode via flag instead of environment variable
if (options.headless) {
camoufoxOptions.headless = true;
}
if (options.args && typeof options.args === "string")
camoufoxOptions.args = options.args.split(",");
if (options.env && typeof options.env === "string") {
@@ -393,6 +337,9 @@ program
? options.profilePath
: undefined,
typeof options.url === "string" ? options.url : undefined,
typeof options.customConfig === "string"
? options.customConfig
: undefined,
);
console.log(
@@ -427,8 +374,71 @@ program
const configs = listCamoufoxConfigs();
console.log(JSON.stringify(configs));
process.exit(0);
} else if (action === "generate-config") {
try {
// Handle geoip option properly
let geoipValue: string | boolean = true; // Default to true
if (options.geoip !== undefined) {
if (typeof options.geoip === "boolean") {
geoipValue = options.geoip;
} else if (typeof options.geoip === "string") {
if (options.geoip === "true") {
geoipValue = true;
} else if (options.geoip === "false") {
geoipValue = false;
} else {
geoipValue = options.geoip; // IP address
}
}
}
const config = await generateCamoufoxConfig({
proxy:
typeof options.proxy === "string" ? options.proxy : undefined,
maxWidth:
typeof options.maxWidth === "number"
? options.maxWidth
: undefined,
maxHeight:
typeof options.maxHeight === "number"
? options.maxHeight
: undefined,
geoip: geoipValue,
blockImages:
typeof options.blockImages === "boolean"
? options.blockImages
: undefined,
blockWebrtc:
typeof options.blockWebrtc === "boolean"
? options.blockWebrtc
: undefined,
blockWebgl:
typeof options.blockWebgl === "boolean"
? options.blockWebgl
: undefined,
executablePath:
typeof options.executablePath === "string"
? options.executablePath
: undefined,
fingerprint:
typeof options.fingerprint === "string"
? options.fingerprint
: undefined,
});
console.log(config);
process.exit(0);
} catch (error: unknown) {
console.error(
`Failed to generate config: ${
error instanceof Error ? error.message : JSON.stringify(error)
}`,
);
process.exit(1);
}
} else {
console.error("Invalid action. Use 'start', 'stop', or 'list'");
console.error(
"Invalid action. Use 'start', 'stop', 'list', or 'generate-config'",
);
process.exit(1);
}
},
+37
View File
@@ -0,0 +1,37 @@
const OS_MAP: { [key: string]: "mac" | "win" | "lin" } = {
darwin: "mac",
linux: "lin",
win32: "win",
};
const OS_NAME: "mac" | "win" | "lin" = OS_MAP[process.platform];
export function getEnvVars(configMap: Record<string, string>) {
const envVars: {
[key: string]: string | number | boolean;
} = {};
let updatedConfigData: Uint8Array;
try {
updatedConfigData = new TextEncoder().encode(JSON.stringify(configMap));
} catch (e) {
console.error(`Error updating config: ${e}`);
process.exit(1);
}
const chunkSize = OS_NAME === "win" ? 2047 : 32767;
const configStr = new TextDecoder().decode(updatedConfigData);
for (let i = 0; i < configStr.length; i += chunkSize) {
const chunk = configStr.slice(i, i + chunkSize);
const envName = `CAMOU_CONFIG_${Math.floor(i / chunkSize) + 1}`;
try {
envVars[envName] = chunk;
} catch (e) {
console.error(`Error setting ${envName}: ${e}`);
process.exit(1);
}
}
return envVars;
}
+3
View File
@@ -153,6 +153,9 @@ importers:
dotenv:
specifier: ^17.2.1
version: 17.2.1
fingerprint-generator:
specifier: ^2.1.69
version: 2.1.69
get-port:
specifier: ^7.1.0
version: 7.1.0
+87 -81
View File
@@ -147,94 +147,94 @@ impl BrowserRunner {
// Handle camoufox profiles using nodecar launcher
if profile.browser == "camoufox" {
if let Some(mut camoufox_config) = profile.camoufox_config.clone() {
// Always start a local proxy for Camoufox (for traffic monitoring and geoip support)
let upstream_proxy = profile
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// Get or create camoufox config
let mut camoufox_config = profile.camoufox_config.clone().unwrap_or_else(|| {
println!(
"Starting local proxy for Camoufox profile: {} (upstream: {})",
profile.name,
upstream_proxy
.as_ref()
.map(|p| format!("{}:{}", p.host, p.port))
.unwrap_or_else(|| "DIRECT".to_string())
);
// Start the proxy and get local proxy settings
let local_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
0, // Use 0 as temporary PID, will be updated later
Some(&profile.name),
)
.await
.map_err(|e| format!("Failed to start local proxy for Camoufox: {e}"))?;
// Format proxy URL for camoufox - always use HTTP for the local proxy
let proxy_url = format!("http://{}:{}", local_proxy.host, local_proxy.port);
// Set proxy in camoufox config
camoufox_config.proxy = Some(proxy_url);
// Ensure geoip is always enabled for proper geolocation spoofing
if camoufox_config.geoip.is_none() {
camoufox_config.geoip = Some(serde_json::Value::Bool(true));
}
println!(
"Configured local proxy for Camoufox: {:?}, geoip: {:?}",
camoufox_config.proxy, camoufox_config.geoip
);
// Use the nodecar camoufox launcher
println!(
"Launching Camoufox via nodecar for profile: {}",
"No camoufox config found for profile {}, using default",
profile.name
);
let camoufox_launcher = crate::camoufox::CamoufoxNodecarLauncher::instance();
let camoufox_result = camoufox_launcher
.launch_camoufox_profile_nodecar(
app_handle.clone(),
profile.clone(),
camoufox_config,
url,
)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to launch camoufox via nodecar: {e}").into()
})?;
crate::camoufox::CamoufoxConfig::default()
});
// For server-based Camoufox, we use the process_id
let process_id = camoufox_result.processId.unwrap_or(0);
println!("Camoufox launched successfully with PID: {process_id}");
// Always start a local proxy for Camoufox (for traffic monitoring and geoip support)
let upstream_proxy = profile
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// Update profile with the process info from camoufox result
let mut updated_profile = profile.clone();
updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
println!(
"Starting local proxy for Camoufox profile: {} (upstream: {})",
profile.name,
upstream_proxy
.as_ref()
.map(|p| format!("{}:{}", p.host, p.port))
.unwrap_or_else(|| "DIRECT".to_string())
);
// Save the updated profile
self.save_process_info(&updated_profile)?;
println!(
"Updated profile with process info: {}",
updated_profile.name
);
// Start the proxy and get local proxy settings
let local_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
0, // Use 0 as temporary PID, will be updated later
Some(&profile.name),
)
.await
.map_err(|e| format!("Failed to start local proxy for Camoufox: {e}"))?;
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
} else {
println!("Emitted profile update event for: {}", updated_profile.name);
}
// Format proxy URL for camoufox - always use HTTP for the local proxy
let proxy_url = format!("http://{}:{}", local_proxy.host, local_proxy.port);
return Ok(updated_profile);
} else {
return Err("Camoufox profile missing configuration".into());
// Set proxy in camoufox config
camoufox_config.proxy = Some(proxy_url);
// Ensure geoip is always enabled for proper geolocation spoofing
if camoufox_config.geoip.is_none() {
camoufox_config.geoip = Some(serde_json::Value::Bool(true));
}
println!(
"Configured local proxy for Camoufox: {:?}, geoip: {:?}",
camoufox_config.proxy, camoufox_config.geoip
);
// Use the nodecar camoufox launcher
println!(
"Launching Camoufox via nodecar for profile: {}",
profile.name
);
let camoufox_launcher = crate::camoufox::CamoufoxNodecarLauncher::instance();
let camoufox_result = camoufox_launcher
.launch_camoufox_profile_nodecar(app_handle.clone(), profile.clone(), camoufox_config, url)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to launch camoufox via nodecar: {e}").into()
})?;
// For server-based Camoufox, we use the process_id
let process_id = camoufox_result.processId.unwrap_or(0);
println!("Camoufox launched successfully with PID: {process_id}");
// Update profile with the process info from camoufox result
let mut updated_profile = profile.clone();
updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
// Save the updated profile
self.save_process_info(&updated_profile)?;
println!(
"Updated profile with process info: {}",
updated_profile.name
);
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
} else {
println!("Emitted profile update event for: {}", updated_profile.name);
}
return Ok(updated_profile);
}
// Create browser instance
@@ -1298,7 +1298,8 @@ impl BrowserRunner {
}
#[tauri::command]
pub fn create_browser_profile(
pub async fn create_browser_profile(
app_handle: tauri::AppHandle,
name: String,
browser: String,
version: String,
@@ -1309,6 +1310,7 @@ pub fn create_browser_profile(
let profile_manager = ProfileManager::instance();
profile_manager
.create_profile(
&app_handle,
&name,
&browser,
&version,
@@ -1316,6 +1318,7 @@ pub fn create_browser_profile(
proxy_id,
camoufox_config,
)
.await
.map_err(|e| format!("Failed to create profile: {e}"))
}
@@ -1603,7 +1606,8 @@ pub async fn kill_browser_profile(
}
#[tauri::command]
pub fn create_browser_profile_new(
pub async fn create_browser_profile_new(
app_handle: tauri::AppHandle,
name: String,
browser_str: String,
version: String,
@@ -1614,6 +1618,7 @@ pub fn create_browser_profile_new(
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
create_browser_profile(
app_handle,
name,
browser_type.as_str().to_string(),
version,
@@ -1621,6 +1626,7 @@ pub fn create_browser_profile_new(
proxy_id,
camoufox_config,
)
.await
}
#[tauri::command]
+182 -302
View File
@@ -9,85 +9,29 @@ use tokio::sync::Mutex as AsyncMutex;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CamoufoxConfig {
pub os: Option<Vec<String>>,
pub proxy: Option<String>,
pub screen_max_width: Option<u32>,
pub screen_max_height: Option<u32>,
pub geoip: Option<serde_json::Value>, // Can be String or bool
pub block_images: Option<bool>,
pub block_webrtc: Option<bool>,
pub block_webgl: Option<bool>,
pub disable_coop: Option<bool>,
pub geoip: Option<serde_json::Value>, // Can be String or bool
pub country: Option<String>,
pub timezone: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub humanize: Option<bool>,
pub humanize_duration: Option<f64>,
pub headless: Option<bool>,
pub locale: Option<Vec<String>>,
pub addons: Option<Vec<String>>,
pub fonts: Option<Vec<String>>,
pub custom_fonts_only: Option<bool>,
pub exclude_addons: Option<Vec<String>>,
pub screen_min_width: Option<u32>,
pub screen_max_width: Option<u32>,
pub screen_min_height: Option<u32>,
pub screen_max_height: Option<u32>,
pub window_width: Option<u32>,
pub window_height: Option<u32>,
pub ff_version: Option<u32>,
pub main_world_eval: Option<bool>,
pub webgl_vendor: Option<String>,
pub webgl_renderer: Option<String>,
pub proxy: Option<String>,
pub enable_cache: Option<bool>,
pub virtual_display: Option<String>,
pub debug: Option<bool>,
pub additional_args: Option<Vec<String>>,
pub env_vars: Option<HashMap<String, String>>,
pub firefox_prefs: Option<HashMap<String, serde_json::Value>>,
pub disable_theming: Option<bool>,
pub showcursor: Option<bool>,
pub executable_path: Option<String>,
pub fingerprint: Option<String>, // JSON string of the complete fingerprint config
}
impl Default for CamoufoxConfig {
fn default() -> Self {
Self {
os: None,
proxy: None,
screen_max_width: None,
screen_max_height: None,
geoip: Some(serde_json::Value::Bool(true)),
block_images: None,
block_webrtc: None,
block_webgl: None,
disable_coop: None,
geoip: Some(serde_json::Value::Bool(true)),
country: None,
timezone: None,
latitude: None,
longitude: None,
humanize: None,
humanize_duration: None,
headless: None,
locale: None,
addons: None,
fonts: None,
custom_fonts_only: None,
exclude_addons: None,
screen_min_width: None,
screen_max_width: None,
screen_min_height: None,
screen_max_height: None,
window_width: None,
window_height: None,
ff_version: None,
main_world_eval: None,
webgl_vendor: None,
webgl_renderer: None,
proxy: None,
enable_cache: Some(true),
virtual_display: None,
debug: None,
additional_args: None,
env_vars: None,
firefox_prefs: None,
disable_theming: Some(true),
showcursor: Some(false),
executable_path: None,
fingerprint: None,
}
}
}
@@ -137,28 +81,95 @@ impl CamoufoxNodecarLauncher {
#[allow(dead_code)]
pub fn create_test_config() -> CamoufoxConfig {
CamoufoxConfig {
// Core anti-fingerprinting settings
screen_min_width: Some(1440),
screen_min_height: Some(900),
// WebGL spoofing
webgl_vendor: Some("Intel Inc.".to_string()),
webgl_renderer: Some("Intel Iris Pro OpenGL Engine".to_string()),
// Humanization
humanize: Some(true),
// Other settings
debug: Some(true),
enable_cache: Some(true),
headless: Some(false), // Not headless for testing
disable_theming: Some(true),
showcursor: Some(false),
screen_max_width: Some(1440),
screen_max_height: Some(900),
geoip: Some(serde_json::Value::Bool(true)),
..Default::default()
}
}
/// Generate Camoufox fingerprint configuration during profile creation
pub async fn generate_fingerprint_config(
&self,
app_handle: &AppHandle,
config: &CamoufoxConfig,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let mut config_args = vec!["camoufox".to_string(), "generate-config".to_string()];
// For fingerprint generation during profile creation, we can pass proxy directly
// but we set geoip to false during tests to avoid network requests
if std::env::var("CAMOUFOX_TEST").is_ok() {
config_args.extend(["--geoip".to_string(), "false".to_string()]);
} else if let Some(geoip) = &config.geoip {
match geoip {
serde_json::Value::Bool(true) => {
config_args.extend(["--geoip".to_string(), "true".to_string()]);
}
serde_json::Value::Bool(false) => {
config_args.extend(["--geoip".to_string(), "false".to_string()]);
}
serde_json::Value::String(ip) => {
config_args.extend(["--geoip".to_string(), ip.clone()]);
}
_ => {}
}
} else {
// Default to true for fingerprint generation
config_args.extend(["--geoip".to_string(), "true".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()]);
}
// Add block_* and executable_path 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());
}
}
if let Some(executable_path) = &config.executable_path {
config_args.extend(["--executable-path".to_string(), executable_path.clone()]);
}
// 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);
}
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());
}
Ok(String::from_utf8_lossy(&config_output.stdout).to_string())
}
/// Get the nodecar sidecar command
fn get_nodecar_sidecar(
&self,
@@ -179,6 +190,82 @@ impl CamoufoxNodecarLauncher {
config: &CamoufoxConfig,
url: Option<&str>,
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
// Generate or use existing configuration
let custom_config = if let Some(existing_fingerprint) = &config.fingerprint {
// Use existing fingerprint from profile metadata
println!("Using existing fingerprint from profile metadata");
existing_fingerprint.clone()
} else {
// Generate new configuration using nodecar generate-config command
println!("Generating new fingerprint configuration");
let mut config_args = vec!["camoufox".to_string(), "generate-config".to_string()];
// Use individual options to build configuration
if let Some(proxy) = &config.proxy {
config_args.extend(["--proxy".to_string(), proxy.clone()]);
}
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(geoip) = &config.geoip {
match geoip {
serde_json::Value::Bool(true) => {
config_args.extend(["--geoip".to_string(), "true".to_string()]);
}
serde_json::Value::Bool(false) => {
config_args.extend(["--geoip".to_string(), "false".to_string()]);
}
serde_json::Value::String(ip) => {
config_args.extend(["--geoip".to_string(), ip.clone()]);
}
_ => {}
}
}
// Always add block_* and executable_path 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());
}
}
if let Some(executable_path) = &config.executable_path {
config_args.extend(["--executable-path".to_string(), executable_path.clone()]);
}
// 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);
}
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 config: {stderr}").into());
}
String::from_utf8_lossy(&config_output.stdout).to_string()
};
// Build nodecar command arguments
let mut args = vec!["camoufox".to_string(), "start".to_string()];
@@ -190,207 +277,12 @@ impl CamoufoxNodecarLauncher {
args.extend(["--url".to_string(), url.to_string()]);
}
// Add configuration options
if let Some(os_list) = &config.os {
let os_str = os_list.join(",");
args.extend(["--os".to_string(), os_str]);
}
// Always add the generated custom config
args.extend(["--custom-config".to_string(), custom_config]);
if let Some(block_images) = config.block_images {
if block_images {
args.push("--block-images".to_string());
}
}
if let Some(block_webrtc) = config.block_webrtc {
if block_webrtc {
args.push("--block-webrtc".to_string());
}
}
if let Some(block_webgl) = config.block_webgl {
if block_webgl {
args.push("--block-webgl".to_string());
}
}
if let Some(disable_coop) = config.disable_coop {
if disable_coop {
args.push("--disable-coop".to_string());
}
}
if let Some(geoip) = &config.geoip {
match geoip {
serde_json::Value::Bool(true) => {
args.extend(["--geoip".to_string(), "auto".to_string()]);
}
serde_json::Value::String(ip) => {
args.extend(["--geoip".to_string(), ip.clone()]);
}
_ => {}
}
}
if let Some(country) = &config.country {
args.extend(["--country".to_string(), country.clone()]);
}
if let Some(timezone) = &config.timezone {
args.extend(["--timezone".to_string(), timezone.clone()]);
}
if let Some(latitude) = config.latitude {
args.extend(["--latitude".to_string(), latitude.to_string()]);
}
if let Some(longitude) = config.longitude {
args.extend(["--longitude".to_string(), longitude.to_string()]);
}
if let Some(humanize) = config.humanize {
if humanize {
if let Some(duration) = config.humanize_duration {
args.extend(["--humanize".to_string(), duration.to_string()]);
} else {
args.push("--humanize".to_string());
}
}
}
if let Some(headless) = config.headless {
if headless {
args.push("--headless".to_string());
}
}
if let Some(locale_list) = &config.locale {
let locale_str = locale_list.join(",");
args.extend(["--locale".to_string(), locale_str]);
}
if let Some(addons) = &config.addons {
let addons_str = addons.join(",");
args.extend(["--addons".to_string(), addons_str]);
}
if let Some(fonts) = &config.fonts {
let fonts_str = fonts.join(",");
args.extend(["--fonts".to_string(), fonts_str]);
}
if let Some(custom_fonts_only) = config.custom_fonts_only {
if custom_fonts_only {
args.push("--custom-fonts-only".to_string());
}
}
if let Some(exclude_addons) = &config.exclude_addons {
let exclude_str = exclude_addons.join(",");
args.extend(["--exclude-addons".to_string(), exclude_str]);
}
if let Some(screen_min_width) = config.screen_min_width {
args.extend([
"--screen-min-width".to_string(),
screen_min_width.to_string(),
]);
}
if let Some(screen_max_width) = config.screen_max_width {
args.extend([
"--screen-max-width".to_string(),
screen_max_width.to_string(),
]);
}
if let Some(screen_min_height) = config.screen_min_height {
args.extend([
"--screen-min-height".to_string(),
screen_min_height.to_string(),
]);
}
if let Some(screen_max_height) = config.screen_max_height {
args.extend([
"--screen-max-height".to_string(),
screen_max_height.to_string(),
]);
}
if let Some(window_width) = config.window_width {
args.extend(["--window-width".to_string(), window_width.to_string()]);
}
if let Some(window_height) = config.window_height {
args.extend(["--window-height".to_string(), window_height.to_string()]);
}
if let Some(ff_version) = config.ff_version {
args.extend(["--ff-version".to_string(), ff_version.to_string()]);
}
if let Some(main_world_eval) = config.main_world_eval {
if main_world_eval {
args.push("--main-world-eval".to_string());
}
}
if let Some(webgl_vendor) = &config.webgl_vendor {
args.extend(["--webgl-vendor".to_string(), webgl_vendor.clone()]);
}
if let Some(webgl_renderer) = &config.webgl_renderer {
args.extend(["--webgl-renderer".to_string(), webgl_renderer.clone()]);
}
if let Some(proxy) = &config.proxy {
args.extend(["--proxy".to_string(), proxy.clone()]);
}
if let Some(enable_cache) = config.enable_cache {
if !enable_cache {
args.push("--disable-cache".to_string());
}
}
if let Some(virtual_display) = &config.virtual_display {
args.extend(["--virtual-display".to_string(), virtual_display.clone()]);
}
if let Some(debug) = config.debug {
if debug {
args.push("--debug".to_string());
}
}
if let Some(additional_args) = &config.additional_args {
let args_str = additional_args.join(",");
args.extend(["--args".to_string(), args_str]);
}
if let Some(env_vars) = &config.env_vars {
let env_json = serde_json::to_string(env_vars)?;
args.extend(["--env".to_string(), env_json]);
}
if let Some(firefox_prefs) = &config.firefox_prefs {
let prefs_json = serde_json::to_string(firefox_prefs)?;
args.extend(["--firefox-prefs".to_string(), prefs_json]);
}
if let Some(disable_theming) = config.disable_theming {
if disable_theming {
args.push("--disable-theming".to_string());
}
}
if let Some(showcursor) = config.showcursor {
if showcursor {
args.push("--showcursor".to_string());
} else {
args.push("--no-showcursor".to_string());
}
// Add headless flag for tests
if std::env::var("CAMOUFOX_HEADLESS").is_ok() {
args.push("--headless".to_string());
}
// Get the nodecar sidecar command
@@ -622,18 +514,8 @@ mod tests {
let test_config = CamoufoxNodecarLauncher::create_test_config();
// Verify test config has expected values
assert_eq!(test_config.screen_min_width, Some(1440));
assert_eq!(test_config.screen_min_height, Some(900));
assert_eq!(test_config.webgl_vendor, Some("Intel Inc.".to_string()));
assert_eq!(
test_config.webgl_renderer,
Some("Intel Iris Pro OpenGL Engine".to_string())
);
assert_eq!(test_config.humanize, Some(true));
assert_eq!(test_config.debug, Some(true));
assert_eq!(test_config.enable_cache, Some(true));
assert_eq!(test_config.headless, Some(false));
// Verify that geoip is enabled by default (from Default implementation)
assert_eq!(test_config.screen_max_width, Some(1440));
assert_eq!(test_config.screen_max_height, Some(900));
assert_eq!(test_config.geoip, Some(serde_json::Value::Bool(true)));
}
@@ -642,11 +524,9 @@ mod tests {
let default_config = CamoufoxConfig::default();
// Verify defaults
assert_eq!(default_config.enable_cache, Some(true));
assert_eq!(default_config.geoip, Some(serde_json::Value::Bool(true)));
assert_eq!(default_config.timezone, None);
assert_eq!(default_config.debug, None);
assert_eq!(default_config.headless, None);
assert_eq!(default_config.proxy, None);
assert_eq!(default_config.fingerprint, None);
}
}
-5
View File
@@ -25,7 +25,6 @@ mod profile;
mod profile_importer;
mod proxy_manager;
mod settings_manager;
mod system_utils;
mod theme_detector;
mod version_updater;
@@ -65,8 +64,6 @@ use profile_importer::{detect_existing_profiles, import_browser_profile};
use theme_detector::get_system_theme;
use system_utils::{get_system_locale, get_system_timezone};
use group_manager::{
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
@@ -478,8 +475,6 @@ pub fn run() {
update_stored_proxy,
delete_stored_proxy,
update_camoufox_config,
get_system_locale,
get_system_timezone,
get_profile_groups,
get_groups_with_profile_counts,
create_profile_group,
+79 -257
View File
@@ -34,8 +34,9 @@ impl ProfileManager {
path
}
pub fn create_profile(
pub async fn create_profile(
&self,
app_handle: &tauri::AppHandle,
name: &str,
browser: &str,
version: &str,
@@ -43,20 +44,50 @@ impl ProfileManager {
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
self.create_profile_with_group(
name,
browser,
version,
release_type,
proxy_id,
camoufox_config,
None,
)
self
.create_profile_with_group(
app_handle,
name,
browser,
version,
release_type,
proxy_id,
camoufox_config,
None,
)
.await
}
// Synchronous version for tests that doesn't generate fingerprints
#[cfg(test)]
pub async fn create_profile_sync(
&self,
app_handle: &tauri::AppHandle,
name: &str,
browser: &str,
version: &str,
release_type: &str,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
self
.create_profile_with_group(
app_handle,
name,
browser,
version,
release_type,
proxy_id,
camoufox_config,
None,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub fn create_profile_with_group(
pub async fn create_profile_with_group(
&self,
app_handle: &tauri::AppHandle,
name: &str,
browser: &str,
version: &str,
@@ -87,6 +118,41 @@ impl ProfileManager {
create_dir_all(&profile_uuid_dir)?;
create_dir_all(&profile_data_dir)?;
// For Camoufox profiles, generate fingerprint during creation
let final_camoufox_config = if browser == "camoufox" {
let mut config = camoufox_config.unwrap_or_else(|| {
println!("Creating default Camoufox config for profile: {name}");
crate::camoufox::CamoufoxConfig::default()
});
// Generate fingerprint if not already provided
if config.fingerprint.is_none() {
println!("Generating fingerprint for Camoufox profile: {name}");
// Use the camoufox launcher to generate the config
let camoufox_launcher = crate::camoufox::CamoufoxNodecarLauncher::instance();
match camoufox_launcher
.generate_fingerprint_config(app_handle, &config)
.await
{
Ok(generated_fingerprint) => {
config.fingerprint = Some(generated_fingerprint);
println!("Successfully generated fingerprint for profile: {name}");
}
Err(e) => {
println!("Warning: Failed to generate fingerprint for profile {name}: {e}");
// Continue with the profile creation even if fingerprint generation fails
}
}
} else {
println!("Using provided fingerprint for Camoufox profile: {name}");
}
Some(config)
} else {
camoufox_config.clone()
};
let profile = BrowserProfile {
id: profile_id,
name: name.to_string(),
@@ -96,7 +162,7 @@ impl ProfileManager {
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
camoufox_config: camoufox_config.clone(),
camoufox_config: final_camoufox_config,
group_id: group_id.clone(),
};
@@ -913,7 +979,7 @@ impl ProfileManager {
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::ProxySettings;
use tempfile::TempDir;
fn create_test_profile_manager() -> (&'static ProfileManager, TempDir) {
@@ -940,250 +1006,6 @@ mod tests {
assert!(profiles_dir.to_string_lossy().contains("DonutBrowser"));
assert!(profiles_dir.to_string_lossy().contains("profiles"));
}
#[test]
fn test_create_profile() {
let (manager, _temp_dir) = create_test_profile_manager();
let profile = manager
.create_profile("Test Profile", "firefox", "139.0", "stable", None, None)
.unwrap();
assert_eq!(profile.name, "Test Profile");
assert_eq!(profile.browser, "firefox");
assert_eq!(profile.version, "139.0");
assert!(profile.proxy_id.is_none());
assert!(profile.process_id.is_none());
}
#[test]
fn test_save_and_load_profile() {
let (manager, _temp_dir) = create_test_profile_manager();
let unique_name = format!("Test Save Load {}", uuid::Uuid::new_v4());
let profile = manager
.create_profile(&unique_name, "firefox", "139.0", "stable", None, None)
.unwrap();
// Save the profile
manager.save_profile(&profile).unwrap();
// Load profiles and verify our profile exists
let profiles = manager.list_profiles().unwrap();
let our_profile = profiles.iter().find(|p| p.name == unique_name).unwrap();
assert_eq!(our_profile.name, unique_name);
assert_eq!(our_profile.browser, "firefox");
assert_eq!(our_profile.version, "139.0");
// Clean up
let _ = manager.delete_profile(&unique_name);
}
#[test]
fn test_rename_profile() {
let (manager, _temp_dir) = create_test_profile_manager();
let original_name = format!("Original Name {}", uuid::Uuid::new_v4());
let new_name = format!("New Name {}", uuid::Uuid::new_v4());
// Create profile
let _ = manager
.create_profile(&original_name, "firefox", "139.0", "stable", None, None)
.unwrap();
// Rename profile
let renamed_profile = manager.rename_profile(&original_name, &new_name).unwrap();
assert_eq!(renamed_profile.name, new_name);
// Verify old profile is gone and new one exists
let profiles = manager.list_profiles().unwrap();
assert!(profiles.iter().any(|p| p.name == new_name));
assert!(!profiles.iter().any(|p| p.name == original_name));
// Clean up
let _ = manager.delete_profile(&new_name);
}
#[test]
fn test_delete_profile() {
let (manager, _temp_dir) = create_test_profile_manager();
let unique_name = format!("To Delete {}", uuid::Uuid::new_v4());
// Create profile
let _ = manager
.create_profile(&unique_name, "firefox", "139.0", "stable", None, None)
.unwrap();
// Verify profile exists
let profiles_before = manager.list_profiles().unwrap();
assert!(profiles_before.iter().any(|p| p.name == unique_name));
// Delete profile
let delete_result = manager.delete_profile(&unique_name);
if let Err(e) = &delete_result {
println!("Delete profile error (may be expected in tests): {e}");
}
// Verify profile is gone
let profiles_after = manager.list_profiles().unwrap();
assert!(!profiles_after.iter().any(|p| p.name == unique_name));
}
#[test]
fn test_profile_name_sanitization() {
let (manager, _temp_dir) = create_test_profile_manager();
// Create profile with spaces and special characters
let profile = manager
.create_profile(
"Test Profile With Spaces",
"firefox",
"139.0",
"stable",
None,
None,
)
.unwrap();
// Profile path should contain UUID and end with /profile
let profiles_dir = manager.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
assert!(profile_data_path
.to_string_lossy()
.contains(&profile.id.to_string()));
assert!(profile_data_path.to_string_lossy().ends_with("/profile"));
// Profile name should remain unchanged
assert_eq!(profile.name, "Test Profile With Spaces");
// Profile should have a valid UUID
assert!(uuid::Uuid::parse_str(&profile.id.to_string()).is_ok());
}
#[test]
fn test_multiple_profiles() {
let (manager, _temp_dir) = create_test_profile_manager();
let profile1_name = format!("Profile 1 {}", uuid::Uuid::new_v4());
let profile2_name = format!("Profile 2 {}", uuid::Uuid::new_v4());
let profile3_name = format!("Profile 3 {}", uuid::Uuid::new_v4());
// Create multiple profiles
let _ = manager
.create_profile(&profile1_name, "firefox", "139.0", "stable", None, None)
.unwrap();
let _ = manager
.create_profile(&profile2_name, "chromium", "1465660", "stable", None, None)
.unwrap();
let _ = manager
.create_profile(&profile3_name, "brave", "v1.81.9", "stable", None, None)
.unwrap();
// List profiles and verify our profiles exist
let profiles = manager.list_profiles().unwrap();
let profile_names: Vec<&str> = profiles.iter().map(|p| p.name.as_str()).collect();
println!("Created profiles: {profile1_name}, {profile2_name}, {profile3_name}");
println!("Found profiles: {profile_names:?}");
assert!(profiles.iter().any(|p| p.name == profile1_name));
assert!(profiles.iter().any(|p| p.name == profile2_name));
assert!(profiles.iter().any(|p| p.name == profile3_name));
// Clean up
let _ = manager.delete_profile(&profile1_name);
let _ = manager.delete_profile(&profile2_name);
let _ = manager.delete_profile(&profile3_name);
}
#[test]
fn test_profile_validation() {
let (manager, _temp_dir) = create_test_profile_manager();
// Test that we can't rename to an existing profile name
let profile1_name = format!("Profile 1 {}", uuid::Uuid::new_v4());
let profile2_name = format!("Profile 2 {}", uuid::Uuid::new_v4());
let _ = manager
.create_profile(&profile1_name, "firefox", "139.0", "stable", None, None)
.unwrap();
let _ = manager
.create_profile(&profile2_name, "firefox", "139.0", "stable", None, None)
.unwrap();
// Try to rename profile2 to profile1's name (should fail)
let result = manager.rename_profile(&profile2_name, &profile1_name);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
// Clean up
let _ = manager.delete_profile(&profile1_name);
let _ = manager.delete_profile(&profile2_name);
}
#[test]
fn test_firefox_default_browser_preferences() {
let (manager, _temp_dir) = create_test_profile_manager();
// Create profile without proxy
let profile = manager
.create_profile(
"Test Firefox Preferences",
"firefox",
"139.0",
"stable",
None,
None,
)
.unwrap();
// Check that user.js file was created with default browser preference
let profiles_dir = manager.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let user_js_path = profile_data_path.join("user.js");
assert!(user_js_path.exists());
let user_js_content = std::fs::read_to_string(user_js_path).unwrap();
assert!(user_js_content.contains("browser.shell.checkDefaultBrowser"));
assert!(user_js_content.contains("false"));
// Verify automatic update disabling preferences are present
assert!(user_js_content.contains("app.update.enabled"));
assert!(user_js_content.contains("app.update.auto"));
// Create profile with proxy (proxy object unused in new architecture)
let _proxy = ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
username: None,
password: None,
};
let profile_with_proxy = manager
.create_profile(
"Test Firefox Preferences Proxy",
"firefox",
"139.0",
"stable",
None, // Tests now use separate proxy storage system
None, // No camoufox config for this test
)
.unwrap();
// Check that user.js file contains both proxy settings and default browser preference
let profile_with_proxy_data_path = profile_with_proxy.get_profile_data_path(&profiles_dir);
let user_js_path_proxy = profile_with_proxy_data_path.join("user.js");
assert!(user_js_path_proxy.exists());
let user_js_content_proxy = std::fs::read_to_string(user_js_path_proxy).unwrap();
assert!(user_js_content_proxy.contains("browser.shell.checkDefaultBrowser"));
assert!(user_js_content_proxy.contains("network.proxy.type"));
// Verify automatic update disabling preferences are present even with proxy
assert!(user_js_content_proxy.contains("app.update.enabled"));
assert!(user_js_content_proxy.contains("app.update.auto"));
}
}
// Global singleton instance
-331
View File
@@ -1,331 +0,0 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SystemLocale {
pub locale: String,
pub language: String,
pub country: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SystemTimezone {
pub timezone: String,
pub offset: String,
}
pub struct SystemUtils;
impl SystemUtils {
pub fn new() -> Self {
Self
}
/// Detect the system's locale settings
pub fn detect_system_locale(&self) -> SystemLocale {
#[cfg(target_os = "macos")]
return macos::detect_system_locale();
#[cfg(target_os = "linux")]
return linux::detect_system_locale();
#[cfg(target_os = "windows")]
return windows::detect_system_locale();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
return SystemLocale {
locale: "en-US".to_string(),
language: "en".to_string(),
country: "US".to_string(),
};
}
/// Detect the system's timezone settings
pub fn detect_system_timezone(&self) -> SystemTimezone {
#[cfg(target_os = "macos")]
return macos::detect_system_timezone();
#[cfg(target_os = "linux")]
return linux::detect_system_timezone();
#[cfg(target_os = "windows")]
return windows::detect_system_timezone();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
return SystemTimezone {
timezone: "UTC".to_string(),
offset: "+00:00".to_string(),
};
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::*;
pub fn detect_system_locale() -> SystemLocale {
// Try to get the system locale from macOS
if let Ok(output) = Command::new("defaults")
.args(["read", "-g", "AppleLocale"])
.output()
{
if output.status.success() {
let locale_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
return parse_locale(&locale_str);
}
}
// Fallback to environment variables
detect_locale_from_env()
}
pub fn detect_system_timezone() -> SystemTimezone {
// Try to get timezone from macOS system
if let Ok(output) = Command::new("date").arg("+%Z").output() {
if output.status.success() {
let tz_abbr = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Get the full timezone name
if let Ok(tz_output) = Command::new("systemsetup").args(["-gettimezone"]).output() {
if tz_output.status.success() {
let tz_full = String::from_utf8_lossy(&tz_output.stdout);
if let Some(tz_name) = tz_full.strip_prefix("Time Zone: ") {
let tz_clean = tz_name.trim().to_string();
if !tz_clean.is_empty() {
return SystemTimezone {
timezone: tz_clean,
offset: tz_abbr,
};
}
}
}
}
}
}
// Fallback to reading /etc/localtime link
detect_timezone_from_files()
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
pub fn detect_system_locale() -> SystemLocale {
// Try to get locale from locale command
if let Ok(output) = Command::new("locale").output() {
if output.status.success() {
let output_str = String::from_utf8_lossy(&output.stdout);
for line in output_str.lines() {
if line.starts_with("LANG=") {
let locale_value = line.strip_prefix("LANG=").unwrap_or("");
let locale_clean = locale_value.trim_matches('"');
return parse_locale(locale_clean);
}
}
}
}
// Fallback to environment variables
detect_locale_from_env()
}
pub fn detect_system_timezone() -> SystemTimezone {
// Try to read /etc/timezone first (Debian/Ubuntu)
if let Ok(tz_content) = std::fs::read_to_string("/etc/timezone") {
let tz_name = tz_content.trim().to_string();
if !tz_name.is_empty() {
return SystemTimezone {
timezone: tz_name,
offset: get_timezone_offset(),
};
}
}
// Try timedatectl (systemd systems)
if let Ok(output) = Command::new("timedatectl")
.args(["show", "--property=Timezone", "--value"])
.output()
{
if output.status.success() {
let tz_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !tz_name.is_empty() {
return SystemTimezone {
timezone: tz_name,
offset: get_timezone_offset(),
};
}
}
}
// Fallback to reading /etc/localtime symlink
detect_timezone_from_files()
}
fn get_timezone_offset() -> String {
if let Ok(output) = Command::new("date").arg("+%z").output() {
if output.status.success() {
return String::from_utf8_lossy(&output.stdout).trim().to_string();
}
}
"+00:00".to_string()
}
}
#[cfg(target_os = "windows")]
mod windows {
use super::*;
pub fn detect_system_locale() -> SystemLocale {
// Try to get locale from Windows registry/powershell
if let Ok(output) = Command::new("powershell")
.args([
"-Command",
"Get-Culture | Select-Object -ExpandProperty Name",
])
.output()
{
if output.status.success() {
let locale_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
return parse_locale(&locale_str);
}
}
// Fallback to environment variables
detect_locale_from_env()
}
pub fn detect_system_timezone() -> SystemTimezone {
// Try to get timezone from Windows
if let Ok(output) = Command::new("powershell")
.args([
"-Command",
"Get-TimeZone | Select-Object -ExpandProperty Id",
])
.output()
{
if output.status.success() {
let tz_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !tz_id.is_empty() {
return SystemTimezone {
timezone: tz_id,
offset: get_windows_timezone_offset(),
};
}
}
}
// Fallback
SystemTimezone {
timezone: "UTC".to_string(),
offset: "+00:00".to_string(),
}
}
fn get_windows_timezone_offset() -> String {
if let Ok(output) = Command::new("powershell")
.args([
"-Command",
"Get-TimeZone | Select-Object -ExpandProperty BaseUtcOffset",
])
.output()
{
if output.status.success() {
let offset_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Convert Windows offset format to standard format
if let Some(colon_pos) = offset_str.find(':') {
let hours = &offset_str[..colon_pos];
let minutes = &offset_str[colon_pos + 1..];
if let (Ok(h), Ok(m)) = (hours.parse::<i32>(), minutes.parse::<i32>()) {
return format!("{:+03}:{:02}", h, m);
}
}
}
}
"+00:00".to_string()
}
}
// Helper functions used across platforms
fn parse_locale(locale_str: &str) -> SystemLocale {
// Remove encoding suffix if present (e.g., "en_US.UTF-8" -> "en_US")
let locale_base = locale_str.split('.').next().unwrap_or(locale_str);
// Split language and country (e.g., "en_US" -> ["en", "US"])
let parts: Vec<&str> = locale_base.split(&['_', '-']).collect();
let language = parts.first().unwrap_or(&"en").to_string();
let country = parts.get(1).unwrap_or(&"US").to_string();
// Convert to standard format (e.g., "en-US")
let standard_locale = if parts.len() >= 2 {
format!("{}-{}", language, country.to_uppercase())
} else {
format!("{language}-US")
};
SystemLocale {
locale: standard_locale,
language,
country: country.to_uppercase(),
}
}
fn detect_locale_from_env() -> SystemLocale {
// Check environment variables in order of preference
let env_vars = ["LANG", "LC_ALL", "LC_CTYPE", "LANGUAGE"];
for var in &env_vars {
if let Ok(value) = std::env::var(var) {
if !value.is_empty() {
return parse_locale(&value);
}
}
}
// Default fallback
SystemLocale {
locale: "en-US".to_string(),
language: "en".to_string(),
country: "US".to_string(),
}
}
fn detect_timezone_from_files() -> SystemTimezone {
// Try to read timezone from /etc/localtime symlink
if let Ok(link_target) = std::fs::read_link("/etc/localtime") {
if let Some(tz_path) = link_target.to_str() {
// Extract timezone name from path like /usr/share/zoneinfo/America/New_York
if let Some(zoneinfo_pos) = tz_path.find("zoneinfo/") {
let tz_name = &tz_path[zoneinfo_pos + 9..];
if !tz_name.is_empty() {
return SystemTimezone {
timezone: tz_name.to_string(),
offset: "+00:00".to_string(), // Could be improved with actual offset calculation
};
}
}
}
}
// Default fallback
SystemTimezone {
timezone: "UTC".to_string(),
offset: "+00:00".to_string(),
}
}
/// Tauri command to get system locale
#[tauri::command]
pub async fn get_system_locale() -> Result<SystemLocale, String> {
let utils = SystemUtils::new();
Ok(utils.detect_system_locale())
}
/// Tauri command to get system timezone
#[tauri::command]
pub async fn get_system_timezone() -> Result<SystemTimezone, String> {
let utils = SystemUtils::new();
Ok(utils.detect_system_timezone())
}
+171 -13
View File
@@ -261,7 +261,6 @@ async fn test_nodecar_camoufox_lifecycle() -> Result<(), Box<dyn std::error::Err
"--profile-path",
profile_path.to_str().unwrap(),
"--headless",
"--debug",
];
println!("Starting Camoufox with nodecar...");
@@ -323,7 +322,6 @@ async fn test_nodecar_camoufox_with_url() -> Result<(), Box<dyn std::error::Erro
"--url",
"https://httpbin.org/get",
"--headless",
"--debug",
];
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 15).await?;
@@ -399,7 +397,6 @@ async fn test_nodecar_camoufox_process_tracking(
"--profile-path",
&instance_profile_path,
"--headless",
"--debug",
];
println!("Starting Camoufox instance {i}...");
@@ -520,17 +517,12 @@ async fn test_nodecar_camoufox_configuration_options(
"start",
"--profile-path",
profile_path.to_str().unwrap(),
"--headless",
"--debug",
"--os",
"linux",
"--block-images",
"--humanize",
"--locale",
"en-US,en-GB",
"--timezone",
"America/New_York",
"--disable-cache",
"--max-width",
"1920",
"--max-height",
"1080",
"--headless",
];
println!("Starting Camoufox with configuration options...");
@@ -579,6 +571,172 @@ async fn test_nodecar_camoufox_configuration_options(
Ok(())
}
/// Test Camoufox generate-config command with basic options
#[tokio::test]
async fn test_nodecar_camoufox_generate_config_basic(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let tracker = TestResourceTracker::new(nodecar_path.clone());
let args = [
"camoufox",
"generate-config",
"--max-width",
"1920",
"--max-height",
"1080",
"--block-images",
];
println!("Testing Camoufox config generation with basic options...");
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 30).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
// If Camoufox is not installed or times out, skip the test
if stderr.contains("not installed")
|| stderr.contains("not found")
|| stderr.contains("timeout")
|| stdout.contains("timeout")
|| stderr.contains("Could not determine OS")
{
println!(
"Skipping Camoufox generate-config test - Camoufox not available or configuration issue"
);
tracker.cleanup_all().await;
return Ok(());
}
tracker.cleanup_all().await;
return Err(
format!("Camoufox generate-config failed - stdout: {stdout}, stderr: {stderr}").into(),
);
}
let stdout = String::from_utf8(output.stdout)?;
println!("Generated config output: {stdout}");
// Parse the generated config as JSON
let config: Value = serde_json::from_str(&stdout)?;
// Verify the config contains expected properties
assert!(
config.is_object(),
"Generated config should be a JSON object"
);
// Check for some expected fingerprint properties
assert!(
config.get("screen.width").is_some(),
"Config should contain screen.width"
);
assert!(
config.get("screen.height").is_some(),
"Config should contain screen.height"
);
assert!(
config.get("navigator.userAgent").is_some(),
"Config should contain navigator.userAgent"
);
println!("Camoufox generate-config basic test completed successfully");
tracker.cleanup_all().await;
Ok(())
}
/// Test Camoufox generate-config command with custom fingerprint
#[tokio::test]
async fn test_nodecar_camoufox_generate_config_custom_fingerprint(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
let tracker = TestResourceTracker::new(nodecar_path.clone());
// Create a custom fingerprint JSON
let custom_fingerprint = r#"{
"screen.width": 1440,
"screen.height": 900,
"navigator.userAgent": "Mozilla/5.0 (Custom) Test Browser",
"navigator.platform": "TestPlatform",
"timezone": "America/New_York",
"locale:language": "en",
"locale:region": "US"
}"#;
let args = [
"camoufox",
"generate-config",
"--fingerprint",
custom_fingerprint,
"--block-webrtc",
];
println!("Testing Camoufox config generation with custom fingerprint...");
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 30).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
// If Camoufox is not installed or has configuration issues, skip the test
if stderr.contains("not installed")
|| stderr.contains("not found")
|| stderr.contains("timeout")
|| stdout.contains("timeout")
|| stderr.contains("Could not determine OS")
{
println!(
"Skipping Camoufox generate-config custom fingerprint test - Camoufox not available or configuration issue"
);
tracker.cleanup_all().await;
return Ok(());
}
tracker.cleanup_all().await;
return Err(
format!("Camoufox generate-config with custom fingerprint failed - stdout: {stdout}, stderr: {stderr}").into(),
);
}
let stdout = String::from_utf8(output.stdout)?;
// Parse the generated config as JSON
let config: Value = serde_json::from_str(&stdout)?;
// Verify the config contains expected properties
assert!(
config.is_object(),
"Generated config should be a JSON object"
);
// Check that our custom values are preserved
assert_eq!(
config.get("screen.width").and_then(|v| v.as_u64()),
Some(1440),
"Custom screen width should be preserved"
);
assert_eq!(
config.get("screen.height").and_then(|v| v.as_u64()),
Some(900),
"Custom screen height should be preserved"
);
assert_eq!(
config.get("navigator.userAgent").and_then(|v| v.as_str()),
Some("Mozilla/5.0 (Custom) Test Browser"),
"Custom user agent should be preserved"
);
assert_eq!(
config.get("timezone").and_then(|v| v.as_str()),
Some("America/New_York"),
"Custom timezone should be preserved"
);
println!("Camoufox generate-config custom fingerprint test completed successfully");
tracker.cleanup_all().await;
Ok(())
}
/// Test nodecar command validation
#[tokio::test]
async fn test_nodecar_command_validation() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
-1
View File
@@ -25,7 +25,6 @@ import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { showErrorToast } from "@/lib/toast-utils";
import { sleep } from "@/lib/utils";
import type { BrowserProfile, CamoufoxConfig, GroupWithCount } from "@/types";
type BrowserTypeString =
+22 -24
View File
@@ -11,7 +11,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getCurrentOS } from "@/lib/browser-utils";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
interface CamoufoxConfigDialogProps {
@@ -28,8 +27,6 @@ export function CamoufoxConfigDialog({
onSave,
}: CamoufoxConfigDialogProps) {
const [config, setConfig] = useState<CamoufoxConfig>({
enable_cache: true,
os: [getCurrentOS()],
geoip: true,
});
const [isSaving, setIsSaving] = useState(false);
@@ -39,8 +36,6 @@ export function CamoufoxConfigDialog({
if (profile && profile.browser === "camoufox") {
setConfig(
profile.camoufox_config || {
enable_cache: true,
os: [getCurrentOS()],
geoip: true,
},
);
@@ -54,12 +49,31 @@ export function CamoufoxConfigDialog({
const handleSave = async () => {
if (!profile) return;
// Validate fingerprint JSON if it exists
if (config.fingerprint) {
try {
JSON.parse(config.fingerprint);
} catch (_error) {
const { toast } = await import("sonner");
toast.error("Invalid fingerprint configuration", {
description:
"The fingerprint configuration contains invalid JSON. Please check your advanced settings.",
});
return;
}
}
setIsSaving(true);
try {
await onSave(profile, config);
onClose();
} catch (error) {
console.error("Failed to save camoufox config:", error);
const { toast } = await import("sonner");
toast.error("Failed to save configuration", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
} finally {
setIsSaving(false);
}
@@ -70,8 +84,6 @@ export function CamoufoxConfigDialog({
if (profile && profile.browser === "camoufox") {
setConfig(
profile.camoufox_config || {
enable_cache: true,
os: [getCurrentOS()],
geoip: true,
},
);
@@ -83,11 +95,7 @@ export function CamoufoxConfigDialog({
return null;
}
// Get the selected OS for warning
const selectedOS = config.os?.[0];
const currentOS = getCurrentOS();
const showOSWarning =
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
// No OS warning needed anymore since we removed OS selection
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
@@ -98,22 +106,12 @@ export function CamoufoxConfigDialog({
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 pr-6 h-[320px]">
<ScrollArea className="flex-1 pr-6 h-[400px]">
<div className="py-4">
{/* OS Warning */}
{showOSWarning && (
<div className="mb-6 p-3 bg-amber-50 rounded-md border border-amber-200">
<p className="text-sm text-amber-800">
Warning: Spoofing OS features is detectable by advanced
anti-bot systems. Some platform-specific APIs and behaviors
cannot be fully replicated.
</p>
</div>
)}
<SharedCamoufoxConfigForm
config={config}
onConfigChange={updateConfig}
forceAdvanced={true}
/>
</div>
</ScrollArea>
+9 -11
View File
@@ -25,7 +25,7 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { getBrowserIcon, getCurrentOS } from "@/lib/browser-utils";
import { getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
type BrowserTypeString =
@@ -109,8 +109,7 @@ export function CreateProfileDialog({
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({
enable_cache: true, // Cache enabled by default
os: [getCurrentOS()], // Default to current OS
geoip: true, // Default to automatic geoip
});
// Common states
@@ -285,13 +284,17 @@ export function CreateProfileDialog({
return;
}
// The fingerprint will be generated at launch time by the Rust backend
// We don't need to generate it here during profile creation
const finalCamoufoxConfig = { ...camoufoxConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "camoufox" as BrowserTypeString,
version: bestCamoufoxVersion.version,
releaseType: bestCamoufoxVersion.releaseType,
proxyId: selectedProxyId,
camoufoxConfig,
camoufoxConfig: finalCamoufoxConfig,
});
}
@@ -314,8 +317,7 @@ export function CreateProfileDialog({
setAvailableReleaseTypes({});
setCamoufoxReleaseTypes({});
setCamoufoxConfig({
enable_cache: true,
os: [getCurrentOS()], // Reset to current OS
geoip: true, // Reset to automatic geoip
});
setActiveTab("regular");
onClose();
@@ -352,11 +354,7 @@ export function CreateProfileDialog({
return isBrowserDownloading(browserStr);
};
// Get the selected OS for warning
const selectedOS = camoufoxConfig.os?.[0];
const currentOS = getCurrentOS();
const _showOSWarning =
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
// No OS warning needed anymore
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
+537
View File
@@ -0,0 +1,537 @@
/** biome-ignore-all lint/a11y/noStaticElementInteractions: temporary suppress until in active use */
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: temporary suppress until in active use */
"use client";
import { Command as CommandPrimitive, useCommandState } from "cmdk";
import * as React from "react";
import { forwardRef, useEffect } from "react";
import { LuX } from "react-icons/lu";
import { cn } from "../lib/utils";
import { Badge } from "./ui/badge";
import { Command, CommandGroup, CommandItem, CommandList } from "./ui/command";
export interface Option {
value: string;
label?: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}
interface MultipleSelectorProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise<Option[]>;
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
"value" | "placeholder" | "disabled"
>;
}
export interface MultipleSelectorRef {
selectedValue: Option[];
input: HTMLInputElement;
}
// eslint-disable-next-line react-refresh/only-export-components
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
function transToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
"": options,
};
}
const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || "";
if (!groupOption[key]) {
groupOption[key] = [option];
} else {
groupOption[key]?.push(option);
}
});
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter(
(val) => !picked.find((p) => p.value === val.value),
);
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (
value.some((option) => targetOption.find((p) => p.value === option.value))
) {
return true;
}
}
return false;
}
/**
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
* So we create one and copy the `Empty` implementation from `cmdk`.
*
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
**/
const CommandEmpty = forwardRef<
HTMLDivElement,
React.ComponentProps<typeof CommandPrimitive.Empty>
>(({ className, ...props }, forwardedRef) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
ref={forwardedRef}
className={cn("py-6 text-sm text-center", className)}
cmdk-empty=""
role="presentation"
{...props}
/>
);
});
CommandEmpty.displayName = "CommandEmpty";
const MultipleSelector = React.forwardRef<
MultipleSelectorRef,
MultipleSelectorProps
>(
(
{
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
}: MultipleSelectorProps,
ref: React.Ref<MultipleSelectorRef>,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const [selected, setSelected] = React.useState<Option[]>(value || []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState("");
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current as HTMLInputElement,
focus: () => inputRef.current?.focus(),
}),
[selected],
);
const handleUnselect = React.useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === "Delete" || e.key === "Backspace") {
if (input.value === "" && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (!lastSelectOption?.fixed) {
// biome-ignore lint/style/noNonNullAssertion: false positive
handleUnselect(selected.at(-1)!);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === "Escape") {
input.blur();
}
}
},
[handleUnselect, selected],
);
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!onSearch || !open) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue("");
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value="-" disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don't have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (
<Command
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn(
"h-auto overflow-visible bg-transparent",
commandProps?.className,
)}
shouldFilter={
commandProps?.shouldFilter !== undefined
? commandProps.shouldFilter
: !onSearch
} // When onSearch is provided, we don't want to filter the options. You can still override it.
filter={commandFilter()}
>
<div
className={cn(
"min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
{
"px-3 py-2": selected.length !== 0,
"cursor-text": !disabled && selected.length !== 0,
},
className,
)}
onClick={() => {
if (disabled) return;
inputRef.current?.focus();
}}
>
<div className="flex flex-wrap gap-1">
{selected.map((option) => {
return (
<Badge
key={option.value}
className={cn(
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
"data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground",
badgeClassName,
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label ?? option.value}
<button
type="button"
className={cn(
"ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
(disabled || option.fixed) && "hidden",
)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
>
<LuX className="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
setOpen(false);
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
if (triggerSearchOnFocus && onSearch) {
onSearch(debouncedSearchTerm);
}
inputProps?.onFocus?.(event);
}}
placeholder={
hidePlaceholderWhenSelected && selected.length !== 0
? ""
: placeholder
}
className={cn(
"flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
{
"w-full": hidePlaceholderWhenSelected,
"px-3 py-2": selected.length === 0,
"ml-1": selected.length !== 0,
},
inputProps?.className,
)}
/>
</div>
</div>
<div className="relative">
{open && (
<CommandList className="absolute top-1 z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in">
{isLoading ? (
loadingIndicator
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && (
<CommandItem value="-" className="hidden" />
)}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup
key={key}
heading={key}
className="overflow-auto h-full"
>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue("");
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
"cursor-pointer",
option.disable &&
"cursor-default text-muted-foreground",
)}
>
{option.label ?? option.value}
</CommandItem>
);
})}
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</Command>
);
},
);
MultipleSelector.displayName = "MultipleSelector";
export default MultipleSelector;
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
+161 -29
View File
@@ -71,41 +71,173 @@ export interface AppUpdateProgress {
}
export interface CamoufoxConfig {
os?: string[];
proxy?: string;
screen_max_width?: number;
screen_max_height?: number;
geoip?: string | boolean;
block_images?: boolean;
block_webrtc?: boolean;
block_webgl?: boolean;
disable_coop?: boolean;
geoip?: string | boolean;
country?: string;
executable_path?: string;
fingerprint?: string; // JSON string of the complete fingerprint config
}
// Extended interface for the advanced fingerprint configuration
export interface CamoufoxFingerprintConfig {
// Navigator properties
"navigator.userAgent"?: string;
"navigator.appVersion"?: string;
"navigator.platform"?: string;
"navigator.oscpu"?: string;
"navigator.appCodeName"?: string;
"navigator.appName"?: string;
"navigator.product"?: string;
"navigator.productSub"?: string;
"navigator.buildID"?: string;
"navigator.language"?: string;
"navigator.languages"?: string[];
"navigator.doNotTrack"?: string;
"navigator.hardwareConcurrency"?: number;
"navigator.maxTouchPoints"?: number;
"navigator.cookieEnabled"?: boolean;
"navigator.globalPrivacyControl"?: boolean;
"navigator.onLine"?: boolean;
// Screen properties
"screen.height"?: number;
"screen.width"?: number;
"screen.availHeight"?: number;
"screen.availWidth"?: number;
"screen.availTop"?: number;
"screen.availLeft"?: number;
"screen.colorDepth"?: number;
"screen.pixelDepth"?: number;
"screen.pageXOffset"?: number;
"screen.pageYOffset"?: number;
// Window properties
"window.outerHeight"?: number;
"window.outerWidth"?: number;
"window.innerHeight"?: number;
"window.innerWidth"?: number;
"window.screenX"?: number;
"window.screenY"?: number;
"window.scrollMinX"?: number;
"window.scrollMinY"?: number;
"window.scrollMaxX"?: number;
"window.scrollMaxY"?: number;
"window.devicePixelRatio"?: number;
"window.history.length"?: number;
// Document properties
"document.body.clientWidth"?: number;
"document.body.clientHeight"?: number;
"document.body.clientTop"?: number;
"document.body.clientLeft"?: number;
// Locale and geolocation
"locale:language"?: string;
"locale:region"?: string;
"locale:script"?: string;
"locale:all"?: string;
"geolocation:latitude"?: number;
"geolocation:longitude"?: number;
"geolocation:accuracy"?: number;
timezone?: string;
latitude?: number;
longitude?: number;
humanize?: boolean;
humanize_duration?: number;
headless?: boolean;
locale?: string[];
addons?: string[];
// Headers
"headers.Accept-Language"?: string;
"headers.User-Agent"?: string;
"headers.Accept-Encoding"?: string;
// WebRTC
"webrtc:ipv4"?: string;
"webrtc:ipv6"?: string;
"webrtc:localipv4"?: string;
"webrtc:localipv6"?: string;
// Battery
"battery:charging"?: boolean;
"battery:chargingTime"?: number;
"battery:dischargingTime"?: number;
"battery:level"?: number;
// Fonts
fonts?: string[];
custom_fonts_only?: boolean;
exclude_addons?: string[];
screen_min_width?: number;
screen_max_width?: number;
screen_min_height?: number;
screen_max_height?: number;
window_width?: number;
window_height?: number;
ff_version?: number;
main_world_eval?: boolean;
webgl_vendor?: string;
webgl_renderer?: string;
proxy?: string;
enable_cache?: boolean;
virtual_display?: string;
"fonts:spacing_seed"?: number;
// Audio
"AudioContext:sampleRate"?: number;
"AudioContext:outputLatency"?: number;
"AudioContext:maxChannelCount"?: number;
// Media devices
"mediaDevices:micros"?: number;
"mediaDevices:webcams"?: number;
"mediaDevices:speakers"?: number;
"mediaDevices:enabled"?: boolean;
// WebGL
"webGl:renderer"?: string;
"webGl:vendor"?: string;
"webGl:supportedExtensions"?: string[];
"webGl2:supportedExtensions"?: string[];
"webGl:contextAttributes"?: {
alpha?: boolean;
antialias?: boolean;
depth?: boolean;
failIfMajorPerformanceCaveat?: boolean;
powerPreference?: string;
premultipliedAlpha?: boolean;
preserveDrawingBuffer?: boolean;
stencil?: boolean;
};
"webGl2:contextAttributes"?: {
alpha?: boolean;
antialias?: boolean;
depth?: boolean;
failIfMajorPerformanceCaveat?: boolean;
powerPreference?: string;
premultipliedAlpha?: boolean;
preserveDrawingBuffer?: boolean;
stencil?: boolean;
};
"webGl:parameters"?: Record<string, unknown>;
"webGl2:parameters"?: Record<string, unknown>;
"webGl:shaderPrecisionFormats"?: Record<string, unknown>;
"webGl2:shaderPrecisionFormats"?: Record<string, unknown>;
// Canvas
"canvas:aaOffset"?: number;
"canvas:aaCapOffset"?: boolean;
// Voices
voices?: Array<{
isLocalService?: boolean;
isDefault?: boolean;
voiceURI?: string;
name?: string;
lang?: string;
}>;
"voices:blockIfNotDefined"?: boolean;
"voices:fakeCompletion"?: boolean;
"voices:fakeCompletion:charsPerSecond"?: number;
// Other properties
humanize?: boolean;
"humanize:maxTime"?: number;
"humanize:minTime"?: number;
showcursor?: boolean;
allowMainWorld?: boolean;
forceScopeAccess?: boolean;
enableRemoteSubframes?: boolean;
disableTheming?: boolean;
memorysaver?: boolean;
addons?: string[];
certificatePaths?: string[];
certificates?: string[];
debug?: boolean;
additional_args?: string[];
env_vars?: Record<string, string>;
firefox_prefs?: Record<string, unknown>;
pdfViewerEnabled?: boolean;
}
export interface CamoufoxLaunchResult {