diff --git a/nodecar/package.json b/nodecar/package.json index 195490b..c1d8317 100644 --- a/nodecar/package.json +++ b/nodecar/package.json @@ -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", diff --git a/nodecar/src/camoufox-launcher.ts b/nodecar/src/camoufox-launcher.ts index c40f1ac..7e8ac14 100644 --- a/nodecar/src/camoufox-launcher.ts +++ b/nodecar/src/camoufox-launcher.ts @@ -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, +): Record { + const camoufoxData: Record = {}; + + // Reverse mappings from fingerprint-generator structure to camoufox keys + const reverseMappings: Record = { + // 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, +): any { + const fingerprintObj: Record = { + navigator: {}, + screen: {}, + videoCard: {}, + headers: {}, + battery: {}, + }; + + // Mapping from camoufox keys to fingerprint-generator structure based on the YAML + const mappings: Record = { + // 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 { // 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 { 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 { + 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}`); + } +} diff --git a/nodecar/src/camoufox-storage.ts b/nodecar/src/camoufox-storage.ts index 34aaf21..669b9f2 100644 --- a/nodecar/src/camoufox-storage.ts +++ b/nodecar/src/camoufox-storage.ts @@ -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"); diff --git a/nodecar/src/camoufox-worker.ts b/nodecar/src/camoufox-worker.ts index 881659f..c307e68 100644 --- a/nodecar/src/camoufox-worker.ts +++ b/nodecar/src/camoufox-worker.ts @@ -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 { 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 { saveCamoufoxConfig(config); - // Monitor for window closure to handle Command+Q properly - + // Monitor for window closure const startWindowMonitoring = () => { windowCheckInterval = setInterval(async () => { try { diff --git a/nodecar/src/index.ts b/nodecar/src/index.ts index 7647254..fd61353 100644 --- a/nodecar/src/index.ts +++ b/nodecar/src/index.ts @@ -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("", "start, stop, or list Camoufox instances") + .argument( + "", + "start, stop, list, or generate-config Camoufox instances", + ) .option("--id ", "Camoufox ID for stop command") .option("--profile-path ", "profile directory path") .option("--url ", "URL to open") - // Operating system fingerprinting - .option( - "--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 URL for config generation") + .option("--max-width ", "maximum screen width", parseInt) + .option("--max-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 address for geolocation spoofing (or 'auto' for automatic)", - ) - .option("--country ", "country code for geolocation") - .option("--timezone ", "timezone to spoof") - .option("--latitude ", "latitude for geolocation", parseFloat) - .option("--longitude ", "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 ", "executable path") + .option("--fingerprint ", "fingerprint JSON string") .option("--headless", "run in headless mode") - - // Localization - .option("--locale ", "locale(s) to use (comma-separated)") - - // Extensions and fonts - .option("--addons ", "Firefox addons to load (comma-separated paths)") - .option("--fonts ", "additional fonts to load (comma-separated)") - .option("--custom-fonts-only", "use only custom fonts, exclude OS fonts") - .option( - "--exclude-addons ", - "default addons to exclude (comma-separated)", - ) - - // Screen and window - .option("--screen-min-width ", "minimum screen width", parseInt) - .option("--screen-max-width ", "maximum screen width", parseInt) - .option("--screen-min-height ", "minimum screen height", parseInt) - .option("--screen-max-height ", "maximum screen height", parseInt) - .option("--window-width ", "fixed window width", parseInt) - .option("--window-height ", "fixed window height", parseInt) - - // Advanced options - .option("--ff-version ", "Firefox version to emulate", parseInt) - .option("--main-world-eval", "enable main world script evaluation") - .option("--webgl-vendor ", "WebGL vendor string") - .option("--webgl-renderer ", "WebGL renderer string") - - // Proxy - .option( - "--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 ", "virtual display number (e.g., :99)") - .option("--debug", "enable debug output") - .option("--args ", "additional browser arguments (comma-separated)") - .option("--env ", "environment variables (JSON string)") - - // Firefox preferences - .option("--firefox-prefs ", "Firefox user preferences (JSON string)") - - // Note: theming and cursor options are hardcoded and not user-configurable + .option("--custom-config ", "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); } }, diff --git a/nodecar/src/utils.ts b/nodecar/src/utils.ts new file mode 100644 index 0000000..2442e1b --- /dev/null +++ b/nodecar/src/utils.ts @@ -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) { + 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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de0785e..694ad32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 9e08527..bac5eec 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -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 { - 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 { + 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] diff --git a/src-tauri/src/camoufox.rs b/src-tauri/src/camoufox.rs index 2784f43..809096b 100644 --- a/src-tauri/src/camoufox.rs +++ b/src-tauri/src/camoufox.rs @@ -9,85 +9,29 @@ use tokio::sync::Mutex as AsyncMutex; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CamoufoxConfig { - pub os: Option>, + pub proxy: Option, + pub screen_max_width: Option, + pub screen_max_height: Option, + pub geoip: Option, // Can be String or bool pub block_images: Option, pub block_webrtc: Option, pub block_webgl: Option, - pub disable_coop: Option, - pub geoip: Option, // Can be String or bool - pub country: Option, - pub timezone: Option, - pub latitude: Option, - pub longitude: Option, - pub humanize: Option, - pub humanize_duration: Option, - pub headless: Option, - pub locale: Option>, - pub addons: Option>, - pub fonts: Option>, - pub custom_fonts_only: Option, - pub exclude_addons: Option>, - pub screen_min_width: Option, - pub screen_max_width: Option, - pub screen_min_height: Option, - pub screen_max_height: Option, - pub window_width: Option, - pub window_height: Option, - pub ff_version: Option, - pub main_world_eval: Option, - pub webgl_vendor: Option, - pub webgl_renderer: Option, - pub proxy: Option, - pub enable_cache: Option, - pub virtual_display: Option, - pub debug: Option, - pub additional_args: Option>, - pub env_vars: Option>, - pub firefox_prefs: Option>, - pub disable_theming: Option, - pub showcursor: Option, + pub executable_path: Option, + pub fingerprint: Option, // 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> { + 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> { + // 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); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c367522..383d1ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index e117b32..64521fa 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -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, camoufox_config: Option, ) -> Result> { - 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, + camoufox_config: Option, + ) -> Result> { + 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 diff --git a/src-tauri/src/system_utils.rs b/src-tauri/src/system_utils.rs deleted file mode 100644 index 557b68a..0000000 --- a/src-tauri/src/system_utils.rs +++ /dev/null @@ -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::(), minutes.parse::()) { - 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 { - let utils = SystemUtils::new(); - Ok(utils.detect_system_locale()) -} - -/// Tauri command to get system timezone -#[tauri::command] -pub async fn get_system_timezone() -> Result { - let utils = SystemUtils::new(); - Ok(utils.detect_system_timezone()) -} diff --git a/src-tauri/tests/nodecar_integration.rs b/src-tauri/tests/nodecar_integration.rs index 6562b35..631dbc0 100644 --- a/src-tauri/tests/nodecar_integration.rs +++ b/src-tauri/tests/nodecar_integration.rs @@ -261,7 +261,6 @@ async fn test_nodecar_camoufox_lifecycle() -> Result<(), Box Result<(), Box Result<(), Box> { + 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> { + 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> { diff --git a/src/app/page.tsx b/src/app/page.tsx index 86694b3..56b7198 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 = diff --git a/src/components/camoufox-config-dialog.tsx b/src/components/camoufox-config-dialog.tsx index f4f6575..bdff7ef 100644 --- a/src/components/camoufox-config-dialog.tsx +++ b/src/components/camoufox-config-dialog.tsx @@ -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({ - 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 ( @@ -98,22 +106,12 @@ export function CamoufoxConfigDialog({ - +
- {/* OS Warning */} - {showOSWarning && ( -
-

- ⚠️ Warning: Spoofing OS features is detectable by advanced - anti-bot systems. Some platform-specific APIs and behaviors - cannot be fully replicated. -

-
- )} -
diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 04f82ac..5bb25d6 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -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({ - 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 ( diff --git a/src/components/multiple-selector.tsx b/src/components/multiple-selector.tsx new file mode 100644 index 0000000..283d42f --- /dev/null +++ b/src/components/multiple-selector.tsx @@ -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; + 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; + /** Props of `CommandInput` */ + inputProps?: Omit< + React.ComponentPropsWithoutRef, + "value" | "placeholder" | "disabled" + >; +} + +export interface MultipleSelectorRef { + selectedValue: Option[]; + input: HTMLInputElement; +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(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 +>(({ className, ...props }, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + + return ( +
+ ); +}); + +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, + ) => { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + const [selected, setSelected] = React.useState(value || []); + const [options, setOptions] = React.useState( + 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) => { + 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 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 = ( + { + 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}"`} + + ); + + // 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 ( + + {emptyIndicator} + + ); + } + + return {emptyIndicator}; + }, [creatable, emptyIndicator, onSearch, options]); + + const selectables = React.useMemo( + () => 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 ( + { + 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()} + > +
{ + if (disabled) return; + inputRef.current?.focus(); + }} + > +
+ {selected.map((option) => { + return ( + + {option.label ?? option.value} + + + ); + })} + {/* Avoid having the "Search" Icon */} + { + 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, + )} + /> +
+
+
+ {open && ( + + {isLoading ? ( + loadingIndicator + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && ( + + )} + {Object.entries(selectables).map(([key, dropdowns]) => ( + + {dropdowns.map((option) => { + return ( + { + 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} + + ); + })} + + ))} + + )} + + )} +
+
+ ); + }, +); + +MultipleSelector.displayName = "MultipleSelector"; +export default MultipleSelector; diff --git a/src/components/shared-camoufox-config-form.tsx b/src/components/shared-camoufox-config-form.tsx index 17b7563..4c932d2 100644 --- a/src/components/shared-camoufox-config-form.tsx +++ b/src/components/shared-camoufox-config-form.tsx @@ -1,7 +1,8 @@ "use client"; -import { invoke } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; +import MultipleSelector, { type Option } from "@/components/multiple-selector"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -12,13 +13,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { CamoufoxConfig } from "@/types"; - -// const osOptions = [ -// { value: "windows", label: "Windows" }, -// { value: "macos", label: "macOS" }, -// { value: "linux", label: "Linux" }, -// ]; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import type { CamoufoxConfig, CamoufoxFingerprintConfig } from "@/types"; const timezoneOptions = [ { value: "America/New_York", label: "America/New_York" }, @@ -85,90 +82,58 @@ const timezoneOptions = [ { value: "America/Mexico_City", label: "America/Mexico_City" }, ]; -const localeOptions = [ - { value: "en-US", label: "English (US)" }, - { value: "en-GB", label: "English (UK)" }, - { value: "en-CA", label: "English (Canada)" }, - { value: "en-AU", label: "English (Australia)" }, - { value: "fr-FR", label: "French (France)" }, - { value: "fr-CA", label: "French (Canada)" }, - { value: "de-DE", label: "German (Germany)" }, - { value: "de-AT", label: "German (Austria)" }, - { value: "de-CH", label: "German (Switzerland)" }, - { value: "es-ES", label: "Spanish (Spain)" }, - { value: "es-MX", label: "Spanish (Mexico)" }, - { value: "es-AR", label: "Spanish (Argentina)" }, - { value: "it-IT", label: "Italian (Italy)" }, - { value: "it-CH", label: "Italian (Switzerland)" }, - { value: "pt-BR", label: "Portuguese (Brazil)" }, - { value: "pt-PT", label: "Portuguese (Portugal)" }, - { value: "ru-RU", label: "Russian (Russia)" }, - { value: "zh-CN", label: "Chinese (Simplified)" }, - { value: "zh-TW", label: "Chinese (Traditional)" }, - { value: "ja-JP", label: "Japanese (Japan)" }, - { value: "ko-KR", label: "Korean (Korea)" }, - { value: "ar-SA", label: "Arabic (Saudi Arabia)" }, - { value: "ar-EG", label: "Arabic (Egypt)" }, - { value: "hi-IN", label: "Hindi (India)" }, - { value: "tr-TR", label: "Turkish (Turkey)" }, - { value: "pl-PL", label: "Polish (Poland)" }, - { value: "nl-NL", label: "Dutch (Netherlands)" }, - { value: "nl-BE", label: "Dutch (Belgium)" }, - { value: "sv-SE", label: "Swedish (Sweden)" }, - { value: "da-DK", label: "Danish (Denmark)" }, - { value: "no-NO", label: "Norwegian (Norway)" }, - { value: "fi-FI", label: "Finnish (Finland)" }, - { value: "he-IL", label: "Hebrew (Israel)" }, - { value: "th-TH", label: "Thai (Thailand)" }, - { value: "vi-VN", label: "Vietnamese (Vietnam)" }, - { value: "id-ID", label: "Indonesian (Indonesia)" }, - { value: "ms-MY", label: "Malay (Malaysia)" }, - { value: "uk-UA", label: "Ukrainian (Ukraine)" }, - { value: "cs-CZ", label: "Czech (Czech Republic)" }, - { value: "sk-SK", label: "Slovak (Slovakia)" }, - { value: "hu-HU", label: "Hungarian (Hungary)" }, - { value: "ro-RO", label: "Romanian (Romania)" }, - { value: "bg-BG", label: "Bulgarian (Bulgaria)" }, - { value: "hr-HR", label: "Croatian (Croatia)" }, - { value: "sr-RS", label: "Serbian (Serbia)" }, - { value: "sl-SI", label: "Slovenian (Slovenia)" }, - { value: "lt-LT", label: "Lithuanian (Lithuania)" }, - { value: "lv-LV", label: "Latvian (Latvia)" }, - { value: "et-EE", label: "Estonian (Estonia)" }, - { value: "el-GR", label: "Greek (Greece)" }, - { value: "ca-ES", label: "Catalan (Spain)" }, - { value: "eu-ES", label: "Basque (Spain)" }, - { value: "gl-ES", label: "Galician (Spain)" }, - { value: "is-IS", label: "Icelandic (Iceland)" }, - { value: "mt-MT", label: "Maltese (Malta)" }, -]; - -// const getCurrentOS = () => { -// if (typeof window !== "undefined") { -// const userAgent = window.navigator.userAgent; -// if (userAgent.includes("Win")) return "windows"; -// if (userAgent.includes("Mac")) return "macos"; -// if (userAgent.includes("Linux")) return "linux"; -// } -// return "unknown"; -// }; - -interface SystemLocale { - locale: string; - language: string; - country: string; -} - -interface SystemTimezone { - timezone: string; - offset: string; -} - interface SharedCamoufoxConfigFormProps { config: CamoufoxConfig; onConfigChange: (key: keyof CamoufoxConfig, value: unknown) => void; className?: string; isCreating?: boolean; // Flag to indicate if this is for creating a new profile + forceAdvanced?: boolean; // Force advanced mode (for editing) +} + +// Component for editing nested objects like webGl:parameters +interface ObjectEditorProps { + value: Record; + onChange: (value: Record) => void; + title: string; +} + +function ObjectEditor({ value, onChange, title }: ObjectEditorProps) { + const [jsonString, setJsonString] = useState( + JSON.stringify(value || {}, null, 2), + ); + const [error, setError] = useState(null); + + // Update jsonString when value changes + useEffect(() => { + setJsonString(JSON.stringify(value || {}, null, 2)); + }, [value]); + + const handleChange = (newValue: string) => { + setJsonString(newValue); + try { + const parsed = JSON.parse(newValue); + setError(null); + onChange(parsed); + } catch (e) { + setError( + `Invalid JSON format: ${e instanceof Error ? e.message : "Unknown error"}`, + ); + } + }; + + return ( +
+ +