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