diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 383d672..2219fd3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -72,20 +72,6 @@ jobs: working-directory: ./src-tauri run: | cargo build - - - name: Build nodecar sidecar - if: matrix.language == 'rust' - shell: bash - working-directory: ./nodecar - run: | - pnpm run build:linux-x64 - - - name: Copy nodecar binary to Tauri binaries - if: matrix.language == 'rust' - shell: bash - run: | - mkdir -p src-tauri/binaries - cp nodecar/nodecar-bin src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu - name: Initialize CodeQL uses: github/codeql-action/init@b1e4dc3db58c9601794e22a9f6d28d45461b9dbf #v3.29.0 diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 55edf47..85bb2f1 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -20,7 +20,6 @@ jobs: --skip-git --lockfile=pnpm-lock.yaml --lockfile=src-tauri/Cargo.lock - --lockfile=nodecar/pnpm-lock.yaml ./ permissions: security-events: write diff --git a/.github/workflows/lint-rs.yml b/.github/workflows/lint-rs.yml index cfa5cae..210a767 100644 --- a/.github/workflows/lint-rs.yml +++ b/.github/workflows/lint-rs.yml @@ -12,7 +12,6 @@ on: pull_request: paths-ignore: - "src/**" - - "nodecar/**" - "package.json" - "pnpm-lock.yaml" - "yarn.lock" @@ -75,35 +74,6 @@ jobs: - name: Install frontend dependencies run: pnpm install --frozen-lockfile - - name: Build nodecar binary - shell: bash - working-directory: ./nodecar - run: | - if [[ "${{ matrix.os }}" == "ubuntu-22.04" ]]; then - pnpm run build:linux-x64 - elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then - pnpm run build:mac-aarch64 - elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then - pnpm run build:win-x64 - fi - - # TODO: replace with an integration test that fetches everything from rust - # - name: Download Camoufox for testing - # run: npx camoufox-js fetch - # continue-on-error: true - - - name: Copy nodecar binary to Tauri binaries - shell: bash - run: | - mkdir -p src-tauri/binaries - if [[ "${{ matrix.os }}" == "ubuntu-22.04" ]]; then - cp nodecar/nodecar-bin src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu - elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then - cp nodecar/nodecar-bin src-tauri/binaries/nodecar-aarch64-apple-darwin - elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then - cp nodecar/nodecar-bin.exe src-tauri/binaries/nodecar-x86_64-pc-windows-msvc.exe - fi - - name: Build frontend run: pnpm next build diff --git a/.github/workflows/osv.yml b/.github/workflows/osv.yml index 52676d0..76adf9b 100644 --- a/.github/workflows/osv.yml +++ b/.github/workflows/osv.yml @@ -23,8 +23,6 @@ on: - "pnpm-lock.yaml" - "src-tauri/Cargo.toml" - "src-tauri/Cargo.lock" - - "nodecar/package.json" - - "nodecar/pnpm-lock.yaml" - ".github/workflows/osv.yml" merge_group: branches: ["main"] @@ -38,8 +36,6 @@ on: - "pnpm-lock.yaml" - "src-tauri/Cargo.toml" - "src-tauri/Cargo.lock" - - "nodecar/package.json" - - "nodecar/pnpm-lock.yaml" permissions: security-events: write @@ -57,7 +53,6 @@ jobs: --skip-git --lockfile=pnpm-lock.yaml --lockfile=src-tauri/Cargo.lock - --lockfile=nodecar/pnpm-lock.yaml ./ scan-pr: @@ -70,5 +65,4 @@ jobs: --skip-git --lockfile=pnpm-lock.yaml --lockfile=src-tauri/Cargo.lock - --lockfile=nodecar/pnpm-lock.yaml ./ diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 8c2341e..77cc385 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -35,7 +35,6 @@ jobs: -r --skip-git --lockfile=pnpm-lock.yaml - --lockfile=nodecar/pnpm-lock.yaml --lockfile=src-tauri/Cargo.lock ./ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ecf12a..93b3e95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,6 @@ jobs: --skip-git --lockfile=pnpm-lock.yaml --lockfile=src-tauri/Cargo.lock - --lockfile=nodecar/pnpm-lock.yaml ./ permissions: security-events: write @@ -71,37 +70,21 @@ jobs: arch: "aarch64" target: "aarch64-apple-darwin" pkg_target: "latest-macos-arm64" - nodecar_script: "build:mac-aarch64" - platform: "macos-latest" args: "--target x86_64-apple-darwin --verbose" arch: "x86_64" target: "x86_64-apple-darwin" pkg_target: "latest-macos-x64" - nodecar_script: "build:mac-x86_64" - platform: "ubuntu-22.04" args: "--target x86_64-unknown-linux-gnu --verbose" arch: "x86_64" target: "x86_64-unknown-linux-gnu" pkg_target: "latest-linux-x64" - nodecar_script: "build:linux-x64" - platform: "ubuntu-22.04-arm" args: "--target aarch64-unknown-linux-gnu --verbose" arch: "aarch64" target: "aarch64-unknown-linux-gnu" pkg_target: "latest-linux-arm64" - nodecar_script: "build:linux-arm64" - # - platform: "windows-latest" - # args: "--target x86_64-pc-windows-msvc --verbose" - # arch: "x86_64" - # target: "x86_64-pc-windows-msvc" - # pkg_target: "latest-win-x64" - # nodecar_script: "build:win-x64" - # - platform: "windows-11-arm" - # args: "--target aarch64-pc-windows-msvc --verbose" - # arch: "aarch64" - # target: "aarch64-pc-windows-msvc" - # pkg_target: "latest-win-arm64" - # nodecar_script: "build:win-arm64" runs-on: ${{ matrix.platform }} steps: @@ -141,26 +124,6 @@ jobs: - name: Install frontend dependencies run: pnpm install --frozen-lockfile - - name: Build nodecar sidecar - shell: bash - working-directory: ./nodecar - run: | - pnpm run ${{ matrix.nodecar_script }} - - - name: Copy nodecar binary to Tauri binaries - shell: bash - run: | - mkdir -p src-tauri/binaries - if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then - cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}.exe - else - cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }} - fi - - # - name: Download Camoufox for testing - # run: npx camoufox-js fetch - # continue-on-error: true - - name: Build frontend run: pnpm exec next build diff --git a/.github/workflows/rolling-release.yml b/.github/workflows/rolling-release.yml index 1f79572..e871f6e 100644 --- a/.github/workflows/rolling-release.yml +++ b/.github/workflows/rolling-release.yml @@ -19,7 +19,6 @@ jobs: --skip-git --lockfile=pnpm-lock.yaml --lockfile=src-tauri/Cargo.lock - --lockfile=nodecar/pnpm-lock.yaml ./ permissions: security-events: write @@ -70,37 +69,26 @@ jobs: arch: "aarch64" target: "aarch64-apple-darwin" pkg_target: "latest-macos-arm64" - nodecar_script: "build:mac-aarch64" - platform: "macos-latest" args: "--target x86_64-apple-darwin --verbose" arch: "x86_64" target: "x86_64-apple-darwin" pkg_target: "latest-macos-x64" - nodecar_script: "build:mac-x86_64" - platform: "ubuntu-22.04" args: "--target x86_64-unknown-linux-gnu --verbose" arch: "x86_64" target: "x86_64-unknown-linux-gnu" pkg_target: "latest-linux-x64" - nodecar_script: "build:linux-x64" - platform: "ubuntu-22.04-arm" args: "--target aarch64-unknown-linux-gnu --verbose" arch: "aarch64" target: "aarch64-unknown-linux-gnu" pkg_target: "latest-linux-arm64" - nodecar_script: "build:linux-arm64" - platform: "windows-latest" args: "--target x86_64-pc-windows-msvc --verbose" arch: "x86_64" target: "x86_64-pc-windows-msvc" pkg_target: "latest-win-x64" - nodecar_script: "build:win-x64" - # - platform: "windows-11-arm" - # args: "--target aarch64-pc-windows-msvc --verbose" - # arch: "aarch64" - # target: "aarch64-pc-windows-msvc" - # pkg_target: "latest-win-arm64" - # nodecar_script: "build:win-arm64" runs-on: ${{ matrix.platform }} steps: @@ -140,26 +128,6 @@ jobs: - name: Install frontend dependencies run: pnpm install --frozen-lockfile - - name: Build nodecar sidecar - shell: bash - working-directory: ./nodecar - run: | - pnpm run ${{ matrix.nodecar_script }} - - - name: Copy nodecar binary to Tauri binaries - shell: bash - run: | - mkdir -p src-tauri/binaries - if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then - cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}.exe - else - cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }} - fi - - # - name: Download Camoufox for testing - # run: npx camoufox-js fetch - # continue-on-error: true - - name: Build frontend run: pnpm exec next build diff --git a/donut-sync/.env.example b/donut-sync/.env.example index 1cff475..1976b7e 100644 --- a/donut-sync/.env.example +++ b/donut-sync/.env.example @@ -1,6 +1,6 @@ SYNC_TOKEN=secret-sync-token -PORT=3939 +PORT=12342 S3_ENDPOINT=http://localhost:8987 S3_REGION=us-east-1 S3_ACCESS_KEY_ID=minioadmin diff --git a/nodecar/copy-binary.sh b/nodecar/copy-binary.sh deleted file mode 100755 index 8a9fae9..0000000 --- a/nodecar/copy-binary.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# Determine file extension based on platform -if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" || "$OSTYPE" == "cygwin" ]]; then - EXT=".exe" -else - EXT="" -fi - -# If architecture provided in the command line, use it to rename the binary in TARGET_TRIPLE -if [ -n "$1" ]; then - TARGET_TRIPLE="$1" -else - RUST_INFO=$(rustc -vV) - TARGET_TRIPLE=$(echo "$RUST_INFO" | grep -o 'host: [^ ]*' | cut -d' ' -f2) -fi - -# Check if target triple was found -if [ -z "$TARGET_TRIPLE" ]; then - echo "Failed to determine platform target triple" >&2 - exit 1 -fi - -# Copy the file with target triple suffix -cp "nodecar-bin" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}" diff --git a/nodecar/package.json b/nodecar/package.json deleted file mode 100644 index 9acc173..0000000 --- a/nodecar/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "nodecar", - "version": "1.0.0", - "description": "", - "main": "dist/index.js", - "bin": "dist/index.js", - "scripts": { - "watch": "nodemon --exec ts-node --esm ./src/index.ts --watch src", - "dev": "node --loader ts-node/esm ./src/index.ts", - "start": "tsc && node ./dist/index.js", - "rename-binary": "sh ./copy-binary.sh", - "build": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary", - "build:mac-aarch64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary", - "build:mac-x86_64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary", - "build:linux-x64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary", - "build:linux-arm64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary", - "build:win-x64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary", - "build:win-arm64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary" - }, - "keywords": [], - "author": "", - "license": "AGPL-3.0", - "dependencies": { - "@types/node": "^25.0.3", - "commander": "^14.0.2", - "donutbrowser-camoufox-js": "^0.7.0", - "dotenv": "^17.2.3", - "fingerprint-generator": "^2.1.79", - "get-port": "^7.1.0", - "nodemon": "^3.1.11", - "playwright-core": "^1.57.0", - "proxy-chain": "^2.7.0", - "tmp": "^0.2.5", - "ts-node": "^10.9.2", - "typescript": "^5.9.3" - }, - "devDependencies": { - "@types/tmp": "^0.2.6" - } -} diff --git a/nodecar/src/camoufox-launcher.ts b/nodecar/src/camoufox-launcher.ts deleted file mode 100644 index 305cc21..0000000 --- a/nodecar/src/camoufox-launcher.ts +++ /dev/null @@ -1,519 +0,0 @@ -import { spawn } from "node:child_process"; -import path from "node:path"; -import { launchOptions } from "donutbrowser-camoufox-js"; -import type { LaunchOptions } from "donutbrowser-camoufox-js/dist/utils.js"; -import { - type CamoufoxConfig, - deleteCamoufoxConfig, - generateCamoufoxId, - getCamoufoxConfig, - listCamoufoxConfigs, - saveCamoufoxConfig, -} from "./camoufox-storage.js"; - -/** - * Convert camoufox fingerprint format to fingerprint-generator format - * @param camoufoxFingerprint The camoufox fingerprint object - * @returns fingerprint-generator object - */ -function convertCamoufoxToFingerprintGenerator( - camoufoxFingerprint: Record, -): any { - const fingerprintObj: Record = { - navigator: {}, - screen: {}, - videoCard: {}, - headers: {}, - battery: {}, - }; - - // Mapping from camoufox keys to fingerprint-generator structure based on the YAML - const mappings: Record = { - // Navigator properties - "navigator.userAgent": "navigator.userAgent", - "navigator.platform": "navigator.platform", - "navigator.hardwareConcurrency": "navigator.hardwareConcurrency", - "navigator.maxTouchPoints": "navigator.maxTouchPoints", - "navigator.doNotTrack": "navigator.doNotTrack", - "navigator.appCodeName": "navigator.appCodeName", - "navigator.appName": "navigator.appName", - "navigator.appVersion": "navigator.appVersion", - "navigator.oscpu": "navigator.oscpu", - "navigator.product": "navigator.product", - "navigator.language": "navigator.language", - "navigator.languages": "navigator.languages", - "navigator.globalPrivacyControl": "navigator.globalPrivacyControl", - - // Screen properties - "screen.width": "screen.width", - "screen.height": "screen.height", - "screen.availWidth": "screen.availWidth", - "screen.availHeight": "screen.availHeight", - "screen.availTop": "screen.availTop", - "screen.availLeft": "screen.availLeft", - "screen.colorDepth": "screen.colorDepth", - "screen.pixelDepth": "screen.pixelDepth", - "window.outerWidth": "screen.outerWidth", - "window.outerHeight": "screen.outerHeight", - "window.innerWidth": "screen.innerWidth", - "window.innerHeight": "screen.innerHeight", - "window.screenX": "screen.screenX", - "window.screenY": "screen.screenY", - "screen.pageXOffset": "screen.pageXOffset", - "screen.pageYOffset": "screen.pageYOffset", - "window.devicePixelRatio": "screen.devicePixelRatio", - "document.body.clientWidth": "screen.clientWidth", - "document.body.clientHeight": "screen.clientHeight", - - // WebGL properties - "webGl:vendor": "videoCard.vendor", - "webGl:renderer": "videoCard.renderer", - - // Headers - "headers.Accept-Encoding": "headers.Accept-Encoding", - - // Battery - "battery:charging": "battery.charging", - "battery:chargingTime": "battery.chargingTime", - "battery:dischargingTime": "battery.dischargingTime", - }; - - // Apply mappings - for (const [camoufoxKey, fingerprintPath] of Object.entries(mappings)) { - if (camoufoxFingerprint[camoufoxKey] !== undefined) { - const pathParts = fingerprintPath.split("."); - let current = fingerprintObj; - - // Navigate to the nested property, creating objects as needed - for (let i = 0; i < pathParts.length - 1; i++) { - const part = pathParts[i]; - if (!current[part]) { - current[part] = {}; - } - current = current[part]; - } - - // Set the final value - const finalKey = pathParts[pathParts.length - 1]; - current[finalKey] = camoufoxFingerprint[camoufoxKey]; - } - } - - // Handle fonts separately - if (camoufoxFingerprint.fonts && Array.isArray(camoufoxFingerprint.fonts)) { - fingerprintObj.fonts = camoufoxFingerprint.fonts; - } - - return { ...camoufoxFingerprint, ...fingerprintObj }; -} - -/** - * Start a Camoufox instance in a separate process - * @param options Camoufox launch options - * @param profilePath Profile directory path - * @param url Optional URL to open - * @returns Promise resolving to the Camoufox configuration - */ -export async function startCamoufoxProcess( - options: LaunchOptions = {}, - profilePath?: string, - url?: string, - customConfig?: string, -): Promise { - // Generate a unique ID for this instance - const id = generateCamoufoxId(); - - // Ensure profile path is absolute if provided - const absoluteProfilePath = profilePath - ? path.resolve(profilePath) - : undefined; - - // Create the Camoufox configuration - const config: CamoufoxConfig = { - id, - options: JSON.parse(JSON.stringify(options)), // Deep clone to avoid reference sharing - profilePath: absoluteProfilePath, - url, - customConfig, - }; - - // Save the configuration before starting the process - saveCamoufoxConfig(config); - - // Build the command arguments - const args = [ - path.join(__dirname, "index.js"), - "camoufox-worker", - "start", - "--id", - id, - ]; - - // Spawn the process with proper detachment - similar to proxy implementation - const child = spawn(process.execPath, args, { - detached: true, - stdio: ["ignore", "pipe", "pipe"], // Capture stdout and stderr for startup feedback - cwd: process.cwd(), - env: { - ...process.env, - NODE_ENV: "production", - // Ensure Camoufox can find its dependencies - NODE_PATH: process.env.NODE_PATH || "", - }, - }); - - // Wait for the worker to start successfully or fail - with shorter timeout for quick response - return new Promise((resolve, reject) => { - let resolved = false; - let stdoutBuffer = ""; - let stderrBuffer = ""; - - // Shorter timeout for quick startup feedback - const timeout = setTimeout(() => { - if (!resolved) { - resolved = true; - child.kill("SIGKILL"); - reject( - new Error(`Camoufox worker ${id} startup timeout after 5 seconds`), - ); - } - }, 5000); - - // Handle stdout - look for success JSON - if (child.stdout) { - child.stdout.on("data", (data) => { - const output = data.toString(); - stdoutBuffer += output; - - // Look for success JSON message - const lines = stdoutBuffer.split("\n"); - for (const line of lines) { - if (line.trim()) { - try { - const parsed = JSON.parse(line.trim()); - if (parsed.success && parsed.id === id && parsed.processId) { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - config.processId = parsed.processId; - saveCamoufoxConfig(config); - - // Unref immediately after success to detach properly - child.unref(); - resolve(config); - return; - } - } - } catch { - // Not JSON, continue - } - } - } - }); - } - - // Handle stderr - look for error JSON - if (child.stderr) { - child.stderr.on("data", (data) => { - const output = data.toString(); - stderrBuffer += output; - - // Look for error JSON message - const lines = stderrBuffer.split("\n"); - for (const line of lines) { - if (line.trim()) { - try { - const parsed = JSON.parse(line.trim()); - if (parsed.error && parsed.id === id) { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - reject( - new Error( - `Camoufox worker failed: ${parsed.message || parsed.error}`, - ), - ); - return; - } - } - } catch { - // Not JSON, continue - } - } - } - }); - } - - child.on("exit", (code, signal) => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - if (code !== 0) { - reject( - new Error( - `Camoufox worker ${id} exited with code ${code} and signal ${signal}. Stderr: ${stderrBuffer}`, - ), - ); - } else { - // Process exited successfully but we didn't get success message - reject( - new Error( - `Camoufox worker ${id} exited without success confirmation`, - ), - ); - } - } - }); - }); -} - -/** - * Check if a process is running by PID - */ -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -/** - * Stop a Camoufox process - * @param id The Camoufox ID to stop - * @returns Promise resolving to true if stopped, false if not found - */ -export async function stopCamoufoxProcess(id: string): Promise { - const config = getCamoufoxConfig(id); - - if (!config) { - return false; - } - - const pid = config.processId; - - try { - // Method 1: If we have a process ID, kill by PID with proper signal sequence - if (pid && isProcessRunning(pid)) { - try { - // First try SIGTERM for graceful shutdown - process.kill(pid, "SIGTERM"); - - // Wait up to 3 seconds for graceful shutdown - for (let i = 0; i < 30; i++) { - await new Promise((resolve) => setTimeout(resolve, 100)); - if (!isProcessRunning(pid)) { - break; - } - } - - // If still running, force kill - if (isProcessRunning(pid)) { - process.kill(pid, "SIGKILL"); - // Wait for SIGKILL to take effect - for (let i = 0; i < 20; i++) { - await new Promise((resolve) => setTimeout(resolve, 100)); - if (!isProcessRunning(pid)) { - break; - } - } - } - } catch { - // Process might have already exited - } - } - - // Method 2: Pattern-based kill as fallback (kills any child processes) - await new Promise((resolve) => { - const killByPattern = spawn( - "pkill", - ["-TERM", "-f", `camoufox-worker.*${id}`], - { stdio: "ignore" }, - ); - killByPattern.on("exit", () => resolve()); - setTimeout(() => resolve(), 1000); - }); - - // Wait a moment then force kill any remaining - await new Promise((resolve) => setTimeout(resolve, 500)); - - await new Promise((resolve) => { - const killByPatternForce = spawn( - "pkill", - ["-KILL", "-f", `camoufox-worker.*${id}`], - { stdio: "ignore" }, - ); - killByPatternForce.on("exit", () => resolve()); - setTimeout(() => resolve(), 1000); - }); - - // Also kill any Firefox processes associated with this profile - if (config.profilePath) { - await new Promise((resolve) => { - const killFirefox = spawn( - "pkill", - ["-KILL", "-f", config.profilePath!], - { stdio: "ignore" }, - ); - killFirefox.on("exit", () => resolve()); - setTimeout(() => resolve(), 1000); - }); - } - - // Verify process is actually dead - if (pid && isProcessRunning(pid)) { - // Last resort: SIGKILL again - try { - process.kill(pid, "SIGKILL"); - } catch { - // Ignore - } - } - - // Delete the configuration - deleteCamoufoxConfig(id); - return true; - } catch { - // Delete the configuration even if stopping failed - deleteCamoufoxConfig(id); - return false; - } -} - -/** - * Stop all Camoufox processes - * @returns Promise resolving when all instances are stopped - */ -export async function stopAllCamoufoxProcesses(): Promise { - const configs = listCamoufoxConfigs(); - - const stopPromises = configs.map((config) => stopCamoufoxProcess(config.id)); - await Promise.all(stopPromises); -} - -interface GenerateConfigOptions { - proxy?: string; - maxWidth?: number; - maxHeight?: number; - minWidth?: number; - minHeight?: number; - geoip?: string | boolean; - blockImages?: boolean; - blockWebrtc?: boolean; - blockWebgl?: boolean; - executablePath?: string; - fingerprint?: string; - os?: "windows" | "macos" | "linux"; -} - -/** - * Generate Camoufox configuration using launchOptions - * @param options Configuration options - * @returns Promise resolving to the generated config JSON string - */ -export async function generateCamoufoxConfig( - options: GenerateConfigOptions, -): Promise { - try { - const launchOpts: any = { - headless: false, - i_know_what_im_doing: true, - config: { - disableTheming: true, - showcursor: false, - }, - }; - - if (options.geoip) { - launchOpts.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 (options.proxy) { - launchOpts.proxy = options.proxy; - } - - // 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); - - if (camoufoxFingerprint.timezone) { - launchOpts.config.timezone = camoufoxFingerprint.timezone; - } - - // 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 - - // Build screen configuration with min/max dimensions - const screen: { - minWidth?: number; - maxWidth?: number; - minHeight?: number; - maxHeight?: number; - } = {}; - - if (options.minWidth) screen.minWidth = options.minWidth; - if (options.maxWidth) screen.maxWidth = options.maxWidth; - if (options.minHeight) screen.minHeight = options.minHeight; - if (options.maxHeight) screen.maxHeight = options.maxHeight; - - if (Object.keys(screen).length > 0) { - launchOpts.screen = screen; - } - } - - launchOpts.allowAddonNewTab = true; - - // Add OS option for fingerprint generation - if (options.os) { - launchOpts.os = options.os; - } - - // Generate the configuration using launchOptions - const generatedOptions = await launchOptions(launchOpts); - - // Extract the environment variables that contain the config - const envVars = generatedOptions.env || {}; - - // Reconstruct the config from environment variables using getEnvVars utility - let configStr = ""; - let chunkIndex = 1; - - while (envVars[`CAMOU_CONFIG_${chunkIndex}`]) { - configStr += envVars[`CAMOU_CONFIG_${chunkIndex}`]; - chunkIndex++; - } - - if (!configStr) { - throw new Error("No configuration generated"); - } - - // Parse and return the config as JSON string - const config = JSON.parse(configStr); - return JSON.stringify(config); - } catch (error) { - throw new Error(`Failed to generate Camoufox config: ${error}`); - } -} diff --git a/nodecar/src/camoufox-storage.ts b/nodecar/src/camoufox-storage.ts deleted file mode 100644 index 886f88d..0000000 --- a/nodecar/src/camoufox-storage.ts +++ /dev/null @@ -1,153 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { LaunchOptions } from "donutbrowser-camoufox-js/dist/utils.js"; -import tmp from "tmp"; - -export interface CamoufoxConfig { - id: string; - options: LaunchOptions; - profilePath?: string; - url?: string; - processId?: number; - customConfig?: string; // JSON string of the fingerprint config -} - -const STORAGE_DIR = path.join(tmp.tmpdir, "donutbrowser", "camoufox"); - -if (!fs.existsSync(STORAGE_DIR)) { - fs.mkdirSync(STORAGE_DIR, { recursive: true }); -} - -/** - * Save a Camoufox configuration to disk - * @param config The Camoufox configuration to save - */ -export function saveCamoufoxConfig(config: CamoufoxConfig): void { - const filePath = path.join(STORAGE_DIR, `${config.id}.json`); - fs.writeFileSync(filePath, JSON.stringify(config, null, 2)); -} - -/** - * Get a Camoufox configuration by ID - * @param id The Camoufox ID - * @returns The Camoufox configuration or null if not found - */ -export function getCamoufoxConfig(id: string): CamoufoxConfig | null { - const filePath = path.join(STORAGE_DIR, `${id}.json`); - - if (!fs.existsSync(filePath)) { - return null; - } - - try { - const content = fs.readFileSync(filePath, "utf-8"); - return JSON.parse(content) as CamoufoxConfig; - } catch (error) { - console.error({ - message: `Error reading Camoufox config ${id}`, - error: (error as Error).message, - }); - return null; - } -} - -/** - * Delete a Camoufox configuration - * @param id The Camoufox ID to delete - * @returns True if deleted, false if not found - */ -export function deleteCamoufoxConfig(id: string): boolean { - const filePath = path.join(STORAGE_DIR, `${id}.json`); - - if (!fs.existsSync(filePath)) { - return false; - } - - try { - fs.unlinkSync(filePath); - return true; - } catch (error) { - console.error({ - message: `Error deleting Camoufox config ${id}`, - error: (error as Error).message, - }); - return false; - } -} - -/** - * List all saved Camoufox configurations - * @returns Array of Camoufox configurations - */ -export function listCamoufoxConfigs(): CamoufoxConfig[] { - if (!fs.existsSync(STORAGE_DIR)) { - return []; - } - - try { - return fs - .readdirSync(STORAGE_DIR) - .filter((file) => file.endsWith(".json")) - .map((file) => { - try { - const content = fs.readFileSync( - path.join(STORAGE_DIR, file), - "utf-8", - ); - return JSON.parse(content) as CamoufoxConfig; - } catch (error) { - console.error({ - message: `Error reading Camoufox config ${file}`, - error, - }); - return null; - } - }) - .filter((config): config is CamoufoxConfig => config !== null) - .map((config) => { - config.options = "Removed for logging" as any; - config.customConfig = "Removed for logging" as any; - return config; - }); - } catch (error) { - console.error({ message: "Error listing Camoufox configs:", error }); - return []; - } -} - -/** - * Update a Camoufox configuration - * @param config The Camoufox configuration to update - * @returns True if updated, false if not found - */ -export function updateCamoufoxConfig(config: CamoufoxConfig): boolean { - const filePath = path.join(STORAGE_DIR, `${config.id}.json`); - - try { - fs.readFileSync(filePath, "utf-8"); - fs.writeFileSync(filePath, JSON.stringify(config, null, 2)); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - console.error({ - message: `Config ${config.id} was deleted while the app was running`, - }); - return false; - } - - console.error({ - message: `Error updating Camoufox config ${config.id}`, - error, - }); - return false; - } -} - -/** - * Generate a unique ID for a Camoufox instance - * @returns A unique ID string - */ -export function generateCamoufoxId(): string { - // Include process ID to ensure uniqueness across multiple processes - return `camoufox_${Date.now()}_${process.pid}_${Math.floor(Math.random() * 10000)}`; -} diff --git a/nodecar/src/camoufox-worker.ts b/nodecar/src/camoufox-worker.ts deleted file mode 100644 index 99f9190..0000000 --- a/nodecar/src/camoufox-worker.ts +++ /dev/null @@ -1,430 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { launchOptions } from "donutbrowser-camoufox-js"; -import type { LaunchOptions } from "donutbrowser-camoufox-js/dist/utils.js"; -import { type Browser, type BrowserContext, firefox } from "playwright-core"; -import tmp from "tmp"; -import { getCamoufoxConfig, saveCamoufoxConfig } from "./camoufox-storage.js"; -import { getEnvVars, parseProxyString } from "./utils.js"; - -// Set up debug logging to a file -const LOG_DIR = path.join(tmp.tmpdir, "donutbrowser", "camoufox-logs"); -if (!fs.existsSync(LOG_DIR)) { - fs.mkdirSync(LOG_DIR, { recursive: true }); -} - -function debugLog(id: string, message: string, data?: any): void { - const logFile = path.join(LOG_DIR, `${id}.log`); - const timestamp = new Date().toISOString(); - const logMessage = data - ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n` - : `[${timestamp}] ${message}\n`; - fs.appendFileSync(logFile, logMessage); -} - -/** - * Run a Camoufox browser server as a worker process - * @param id The Camoufox configuration ID - */ -export async function runCamoufoxWorker(id: string): Promise { - debugLog(id, "Worker starting", { pid: process.pid }); - - // Get the Camoufox configuration - debugLog(id, "Loading Camoufox configuration"); - const config = getCamoufoxConfig(id); - - if (!config) { - debugLog(id, "Configuration not found"); - console.error( - JSON.stringify({ - error: "Configuration not found", - id: id, - }), - ); - process.exit(1); - } - - debugLog(id, "Configuration loaded successfully", { - profilePath: config.profilePath, - hasOptions: !!config.options, - hasCustomConfig: !!config.customConfig, - hasUrl: !!config.url, - }); - - config.processId = process.pid; - saveCamoufoxConfig(config); - - console.log( - JSON.stringify({ - success: true, - id: id, - processId: process.pid, - profilePath: config.profilePath, - message: "Camoufox worker started successfully", - }), - ); - - // Launch browser in background - this can take time and may fail - setImmediate(async () => { - debugLog(id, "Starting browser launch in background"); - let browser: Browser | null = null; - let context: BrowserContext | null = null; - let windowCheckInterval: NodeJS.Timeout | null = null; - - // Graceful shutdown handler with access to browser and server - const gracefulShutdown = async () => { - debugLog(id, "Graceful shutdown initiated"); - try { - // Clear any intervals first - if (windowCheckInterval) { - clearInterval(windowCheckInterval); - } - - // Close browser context and server if they exist - if (context && !context.pages) { - // Context is already closed - } else if (context) { - await context.close(); - } - - if (browser?.isConnected()) { - await browser.close(); - } - } catch { - // Ignore cleanup errors during shutdown - } - process.exit(0); - }; - - // Handle various quit signals for proper macOS Command+Q support - process.on("SIGTERM", () => void gracefulShutdown()); - process.on("SIGINT", () => void gracefulShutdown()); - process.on("SIGHUP", () => void gracefulShutdown()); - process.on("SIGQUIT", () => void gracefulShutdown()); - - // Handle uncaught exceptions and unhandled rejections - process.on("uncaughtException", () => void gracefulShutdown()); - process.on("unhandledRejection", () => void gracefulShutdown()); - - try { - debugLog(id, "Preparing launch options"); - // Deep clone to avoid reference sharing and ensure fresh configuration for each instance - const camoufoxOptions: LaunchOptions = JSON.parse( - JSON.stringify(config.options || {}), - ); - debugLog(id, "Base options cloned", { - hasOptions: Object.keys(camoufoxOptions).length, - }); - - // Add profile path if provided - if (config.profilePath) { - camoufoxOptions.user_data_dir = config.profilePath; - debugLog(id, "Set user_data_dir", { profilePath: config.profilePath }); - } - - // Ensure block options are properly set - if (camoufoxOptions.block_images) { - camoufoxOptions.block_images = true; - } - - if (camoufoxOptions.block_webgl) { - camoufoxOptions.block_webgl = true; - } - - 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 - ensure they are applied for each instance - camoufoxOptions.i_know_what_im_doing = true; - camoufoxOptions.config = { - disableTheming: true, - showcursor: false, - ...(camoufoxOptions.config || {}), - }; - debugLog(id, "Set default options", { - i_know_what_im_doing: true, - disableTheming: true, - showcursor: false, - }); - - // Generate fresh options for this specific instance - debugLog(id, "Generating launch options via launchOptions function"); - const generatedOptions = await launchOptions(camoufoxOptions); - debugLog(id, "Launch options generated successfully", { - hasEnv: !!generatedOptions.env, - argsLength: generatedOptions.args?.length || 0, - }); - - // Start with process environment to ensure proper inheritance - let finalEnv = { ...process.env }; - debugLog(id, "Base environment variables set", { - envVarCount: Object.keys(finalEnv).length, - }); - - // Add generated options environment variables - if (generatedOptions.env) { - finalEnv = { ...finalEnv, ...generatedOptions.env }; - debugLog(id, "Added generated environment variables", { - generatedEnvCount: Object.keys(generatedOptions.env).length, - totalEnvCount: Object.keys(finalEnv).length, - }); - } - - // If we have a custom config from Rust, use it directly as environment variables - if (config.customConfig) { - debugLog(id, "Processing custom config", { - customConfigLength: config.customConfig.length, - }); - try { - // Parse the custom config JSON string - const customConfigObj = JSON.parse(config.customConfig); - debugLog(id, "Custom config parsed successfully", { - customConfigKeys: Object.keys(customConfigObj), - }); - - // Ensure default config values are preserved even with custom config - const mergedConfig = { - ...customConfigObj, - disableTheming: true, - showcursor: false, - // allowAddonNewTab will be handled from the fingerprint config if present - }; - - // Convert merged config to environment variables using getEnvVars - const customEnvVars = getEnvVars(mergedConfig); - debugLog(id, "Custom config converted to environment variables", { - customEnvVarCount: Object.keys(customEnvVars).length, - }); - - // Merge custom config with generated config (custom takes precedence) - finalEnv = { ...finalEnv, ...customEnvVars }; - debugLog(id, "Custom config merged with final environment", { - finalEnvCount: Object.keys(finalEnv).length, - }); - } catch (error) { - debugLog(id, "Failed to parse custom config", { - error: error instanceof Error ? error.message : String(error), - }); - console.error( - `Camoufox worker ${id}: Failed to parse custom config, using generated config:`, - error, - ); - await gracefulShutdown(); - return; - } - } else { - debugLog(id, "No custom config provided"); - } - // Prepare profile path for persistent context - const profilePath = config.profilePath || ""; - debugLog(id, "Profile path prepared", { profilePath }); - - // Launch persistent context with the final configuration - const finalOptions: any = { - ...generatedOptions, - env: finalEnv, - }; - debugLog(id, "Final launch options prepared", { - hasExecutablePath: !!finalOptions.executablePath, - hasProxy: !!camoufoxOptions.proxy, - profilePath, - }); - - // If a custom executable path was provided, ensure Playwright uses it - if ( - (camoufoxOptions as any).executable_path && - typeof (camoufoxOptions as any).executable_path === "string" - ) { - finalOptions.executablePath = (camoufoxOptions as any) - .executable_path as string; - debugLog(id, "Custom executable path set", { - executablePath: finalOptions.executablePath, - }); - } - - // Only add proxy if it exists and is valid - if (camoufoxOptions.proxy) { - debugLog(id, "Processing proxy configuration", { - proxyString: camoufoxOptions.proxy, - }); - try { - finalOptions.proxy = parseProxyString(camoufoxOptions.proxy); - debugLog(id, "Proxy parsed successfully"); - } catch (error) { - debugLog(id, "Failed to parse proxy", { - error: error instanceof Error ? error.message : String(error), - }); - console.error({ - message: "Failed to parse proxy, launching without proxy", - error, - }); - await gracefulShutdown(); - return; - } - } - - // Use launchPersistentContext instead of launchServer - debugLog(id, "Launching persistent context", { profilePath }); - context = await firefox.launchPersistentContext( - profilePath, - finalOptions, - ); - debugLog(id, "Persistent context launched successfully"); - - // Get the browser instance from context - browser = context.browser(); - debugLog(id, "Browser instance obtained from context", { - browserConnected: browser?.isConnected(), - }); - - // Handle browser disconnection for proper cleanup - if (browser) { - browser.on("disconnected", () => void gracefulShutdown()); - debugLog(id, "Browser disconnect handler registered"); - } - - // Handle context close for proper cleanup - context.on("close", () => void gracefulShutdown()); - debugLog(id, "Context close handler registered"); - - saveCamoufoxConfig(config); - - // Monitor for window closure - const startWindowMonitoring = () => { - debugLog(id, "Starting window monitoring"); - windowCheckInterval = setInterval(async () => { - try { - // Check if context is still active - if (!context?.pages || context.pages().length === 0) { - debugLog(id, "No pages found in context, shutting down"); - if (windowCheckInterval) { - clearInterval(windowCheckInterval); - } - await gracefulShutdown(); - return; - } - - // Check if browser is still connected (if available) - if (browser && !browser.isConnected()) { - debugLog(id, "Browser disconnected, shutting down"); - if (windowCheckInterval) { - clearInterval(windowCheckInterval); - } - await gracefulShutdown(); - return; - } - - // Check pages in the persistent context - const pages = context.pages(); - if (pages.length === 0) { - debugLog(id, "No pages in context, shutting down"); - if (windowCheckInterval) { - clearInterval(windowCheckInterval); - } - await gracefulShutdown(); - } - } catch (error) { - debugLog(id, "Error in window monitoring", { - error: error instanceof Error ? error.message : String(error), - }); - // If we can't check windows, assume browser is closing - if (windowCheckInterval) { - clearInterval(windowCheckInterval); - } - await gracefulShutdown(); - } - }, 1000); // Check every second - }; - - // Handle URL opening if provided - if (config.url) { - debugLog(id, "Opening URL in browser", { url: config.url }); - try { - const pages = await context.pages(); - if (pages.length) { - const page = pages[0]; - debugLog(id, "Navigating to URL"); - await page.goto(config.url, { - waitUntil: "domcontentloaded", - timeout: 30000, - }); - debugLog(id, "URL opened successfully"); - - // Start monitoring after page is created - startWindowMonitoring(); - } else { - debugLog(id, "No pages available to open URL"); - startWindowMonitoring(); - } - } catch (urlError) { - debugLog(id, "Failed to open URL", { - error: - urlError instanceof Error ? urlError.message : String(urlError), - }); - console.error({ - message: "Failed to open URL", - error: urlError, - }); - // URL opening failure doesn't affect startup success - // Still start monitoring - startWindowMonitoring(); - } - } else { - debugLog(id, "No URL provided, starting monitoring"); - // Start monitoring after page is created - startWindowMonitoring(); - } - - // Monitor browser/context connection - debugLog(id, "Starting keep-alive monitoring"); - const keepAlive = setInterval(async () => { - try { - // Check if context is still active - if (!context?.pages) { - debugLog(id, "Context not active in keep-alive, shutting down"); - clearInterval(keepAlive); - await gracefulShutdown(); - return; - } - - // Check browser connection if available - if (browser && !browser.isConnected()) { - debugLog(id, "Browser not connected in keep-alive, shutting down"); - clearInterval(keepAlive); - await gracefulShutdown(); - return; - } - } catch (error) { - debugLog(id, "Error in keep-alive check", { - error: error instanceof Error ? error.message : String(error), - }); - console.error({ - message: "Error in keepAlive check", - error, - }); - clearInterval(keepAlive); - await gracefulShutdown(); - } - }, 2000); - } catch (error) { - debugLog(id, "Failed to launch Camoufox", { - error: error instanceof Error ? error.message : String(error), - }); - console.error({ - message: "Failed to launch Camoufox", - error, - }); - // Browser launch failed, attempt cleanup - await gracefulShutdown(); - } - }); - - // Keep process alive - process.stdin.resume(); -} diff --git a/nodecar/src/index.ts b/nodecar/src/index.ts deleted file mode 100644 index eb43ac3..0000000 --- a/nodecar/src/index.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { program } from "commander"; -import type { LaunchOptions } from "donutbrowser-camoufox-js/dist/utils.js"; -import { - generateCamoufoxConfig, - startCamoufoxProcess, - stopAllCamoufoxProcesses, - stopCamoufoxProcess, -} from "./camoufox-launcher.js"; -import { listCamoufoxConfigs } from "./camoufox-storage.js"; -import { runCamoufoxWorker } from "./camoufox-worker.js"; - -// Command for Camoufox management -program - .command("camoufox") - .argument( - "", - "start, stop, list, or generate-config Camoufox instances", - ) - .option("--id ", "Camoufox ID for stop command") - .option("--profile-path ", "profile directory path") - .option("--url ", "URL to open") - - // Config generation options - .option("--proxy ", "proxy URL for config generation") - .option("--max-width ", "maximum screen width", parseInt) - .option("--max-height ", "maximum screen height", parseInt) - .option("--min-width ", "minimum screen width", parseInt) - .option("--min-height ", "minimum screen height", parseInt) - .option("--geoip", "enable geoip") - .option("--block-images", "block images") - .option("--block-webrtc", "block WebRTC") - .option("--block-webgl", "block WebGL") - .option("--executable-path ", "executable path") - .option("--fingerprint ", "fingerprint JSON string") - .option("--headless", "run in headless mode") - .option("--custom-config ", "custom config JSON string") - .option( - "--os ", - "operating system for fingerprint: windows, macos, linux", - ) - - .description("manage Camoufox browser instances") - .action( - async ( - action: string, - options: Record, - ) => { - if (action === "start") { - try { - // Build Camoufox options in the format expected by camoufox-js - const camoufoxOptions: LaunchOptions = {}; - - // OS fingerprinting - if (options.os && typeof options.os === "string") { - camoufoxOptions.os = options.os.includes(",") - ? (options.os.split(",") as ("windows" | "macos" | "linux")[]) - : (options.os as "windows" | "macos" | "linux"); - } - - // Blocking options - if (options.blockImages) camoufoxOptions.block_images = true; - if (options.blockWebrtc) camoufoxOptions.block_webrtc = true; - if (options.blockWebgl) camoufoxOptions.block_webgl = true; - - // Security options - if (options.disableCoop) camoufoxOptions.disable_coop = true; - - if (options.geoip) { - camoufoxOptions.geoip = true; - } - - if (options.latitude && options.longitude) { - camoufoxOptions.geolocation = { - latitude: options.latitude as number, - longitude: options.longitude as number, - accuracy: 100, - }; - } - if (options.country) - camoufoxOptions.country = options.country as string; - if (options.timezone) - camoufoxOptions.timezone = options.timezone as string; - - if (options.humanize) - camoufoxOptions.humanize = options.humanize as boolean; - if (options.headless) camoufoxOptions.headless = true; - - // Localization - if (options.locale && typeof options.locale === "string") { - camoufoxOptions.locale = options.locale.includes(",") - ? options.locale.split(",") - : options.locale; - } - - // Extensions and fonts - if (options.addons && typeof options.addons === "string") - camoufoxOptions.addons = options.addons.split(","); - if (options.fonts && typeof options.fonts === "string") - camoufoxOptions.fonts = options.fonts.split(","); - if (options.customFontsOnly) camoufoxOptions.custom_fonts_only = true; - if ( - options.excludeAddons && - typeof options.excludeAddons === "string" - ) - camoufoxOptions.exclude_addons = options.excludeAddons.split( - ",", - ) as "UBO"[]; - - // Executable path: forward through to camoufox-js and ultimately Playwright - if ( - options.executablePath && - typeof options.executablePath === "string" - ) { - // camoufox-js uses snake_case for this option - (camoufoxOptions as any).executable_path = - options.executablePath as string; - } - - // Screen and window - const screen: { - minWidth?: number; - maxWidth?: number; - minHeight?: number; - maxHeight?: number; - } = {}; - if (options.screenMinWidth) - screen.minWidth = options.screenMinWidth as number; - if (options.screenMaxWidth) - screen.maxWidth = options.screenMaxWidth as number; - if (options.screenMinHeight) - screen.minHeight = options.screenMinHeight as number; - if (options.screenMaxHeight) - screen.maxHeight = options.screenMaxHeight as number; - if (Object.keys(screen).length > 0) camoufoxOptions.screen = screen; - - if (options.windowWidth && options.windowHeight) { - camoufoxOptions.window = [ - options.windowWidth as number, - options.windowHeight as number, - ]; - } - - // Advanced options - if (options.ffVersion) - camoufoxOptions.ff_version = options.ffVersion as number; - if (options.mainWorldEval) camoufoxOptions.main_world_eval = true; - if (options.webglVendor && options.webglRenderer) { - camoufoxOptions.webgl_config = [ - options.webglVendor as string, - options.webglRenderer as string, - ]; - } - - // Proxy - if (options.proxy) camoufoxOptions.proxy = options.proxy as string; - - // Cache and performance - default to enabled - camoufoxOptions.enable_cache = !options.disableCache; - - // Environment and debugging - 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") { - try { - camoufoxOptions.env = JSON.parse(options.env); - } catch (e) { - console.error( - JSON.stringify({ - error: "Invalid JSON for --env option", - message: String(e), - }), - ); - process.exit(1); - return; - } - } - - // Firefox preferences - if ( - options.firefoxPrefs && - typeof options.firefoxPrefs === "string" - ) { - try { - camoufoxOptions.firefox_user_prefs = JSON.parse( - options.firefoxPrefs, - ); - } catch (e) { - console.error( - JSON.stringify({ - error: "Invalid JSON for --firefox-prefs option", - message: String(e), - }), - ); - process.exit(1); - } - } - - const config = await startCamoufoxProcess( - camoufoxOptions, - typeof options.profilePath === "string" - ? options.profilePath - : undefined, - typeof options.url === "string" ? options.url : undefined, - typeof options.customConfig === "string" - ? options.customConfig - : undefined, - ); - - console.log( - JSON.stringify({ - id: config.id, - processId: config.processId, - profilePath: config.profilePath, - url: config.url, - }), - ); - - process.exit(0); - } catch (error: unknown) { - console.error( - JSON.stringify({ - error: "Failed to start Camoufox", - message: error instanceof Error ? error.message : String(error), - }), - ); - process.exit(1); - } - } else if (action === "stop") { - if (options.id && typeof options.id === "string") { - const stopped = await stopCamoufoxProcess(options.id); - console.log(JSON.stringify({ success: stopped })); - } else { - await stopAllCamoufoxProcesses(); - console.log(JSON.stringify({ success: true })); - } - process.exit(0); - } else if (action === "list") { - const configs = listCamoufoxConfigs(); - console.log(JSON.stringify(configs)); - process.exit(0); - } else if (action === "generate-config") { - try { - 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, - minWidth: - typeof options.minWidth === "number" - ? options.minWidth - : undefined, - minHeight: - typeof options.minHeight === "number" - ? options.minHeight - : undefined, - geoip: Boolean(options.geoip), - 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, - os: - typeof options.os === "string" - ? (options.os as "windows" | "macos" | "linux") - : undefined, - }); - console.log(config); - process.exit(0); - } catch (error: unknown) { - console.error({ - error: "Failed to generate config", - message: - error instanceof Error ? error.message : JSON.stringify(error), - }); - process.exit(1); - } - } else { - console.error({ - error: "Invalid action", - message: "Use 'start', 'stop', 'list', or 'generate-config'", - }); - process.exit(1); - } - }, - ); - -// Command for Camoufox worker (internal use) -program - .command("camoufox-worker") - .argument("", "start a Camoufox worker") - .requiredOption("--id ", "Camoufox configuration ID") - .description("run a Camoufox worker process") - .action(async (action: string, options: { id: string }) => { - if (action === "start") { - await runCamoufoxWorker(options.id); - } else { - console.error({ - error: "Invalid action for camoufox-worker", - message: "Use 'start'", - }); - process.exit(1); - } - }); - -program.parse(); diff --git a/nodecar/src/utils.ts b/nodecar/src/utils.ts deleted file mode 100644 index dfad275..0000000 --- a/nodecar/src/utils.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { LaunchOptions } from "playwright-core"; - -const OS_MAP: { [key: string]: "mac" | "win" | "lin" } = { - darwin: "mac", - linux: "lin", - win32: "win", -}; - -const OS_NAME: "mac" | "win" | "lin" = OS_MAP[process.platform]; - -export function getEnvVars(configMap: Record) { - const envVars: { - [key: string]: string | undefined; - } = {}; - let updatedConfigData: Uint8Array; - - try { - // Ensure we're working with a fresh copy of the config - const configCopy = JSON.parse(JSON.stringify(configMap)); - updatedConfigData = new TextEncoder().encode(JSON.stringify(configCopy)); - } 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; -} - -export function parseProxyString(proxyString: LaunchOptions["proxy"] | string) { - if (typeof proxyString === "object") { - return proxyString; - } - - if (!proxyString || typeof proxyString !== "string") { - throw new Error("Invalid proxy string provided"); - } - - // Remove any leading/trailing whitespace - const trimmed = proxyString.trim(); - - // Handle different proxy string formats: - // 1. http://username:password@host:port - // 2. host:port - // 3. protocol://host:port - // 4. username:password@host:port - - let server = ""; - let username: string | undefined; - let password: string | undefined; - - try { - // Try parsing as URL first (handles protocol://username:password@host:port) - if (trimmed.includes("://")) { - const url = new URL(trimmed); - // Playwright accepts short form "host:port" for HTTP proxies - server = `${url.hostname}:${url.port}`; - - if (url.username) { - username = decodeURIComponent(url.username); - } - if (url.password) { - password = decodeURIComponent(url.password); - } - } else { - // Handle formats without protocol - let workingString = trimmed; - - // Check for username:password@ prefix - const authMatch = workingString.match(/^([^:@]+):([^@]+)@(.+)$/); - if (authMatch) { - username = authMatch[1]; - password = authMatch[2]; - workingString = authMatch[3]; - } - - // The remaining part should be host:port - server = workingString; - } - - // Validate that we have a server - if (!server) { - throw new Error("Could not extract server information"); - } - - // Basic validation for host:port format - if (!server.includes(":") || server.split(":").length !== 2) { - throw new Error("Server must be in host:port format"); - } - - const result: LaunchOptions["proxy"] = { server }; - - if (username !== undefined) { - result.username = username; - } - - if (password !== undefined) { - result.password = password; - } - - return result; - } catch (error) { - throw new Error( - `Failed to parse proxy string: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - } -} diff --git a/nodecar/tsconfig.json b/nodecar/tsconfig.json deleted file mode 100644 index 4f86f47..0000000 --- a/nodecar/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "CommonJS", - "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], - "sourceMap": false, - "outDir": "dist", - "rootDir": "src", - "strict": true, - "types": ["node"], - "esModuleInterop": true, - "moduleResolution": "node", - "resolveJsonModule": true, - "baseUrl": ".", - "allowSyntheticDefaultImports": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "removeComments": true - } -} diff --git a/package.json b/package.json index 2d0e8e6..8f765a0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "version": "0.13.9", "type": "module", "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev --turbopack -p 12341", "build": "next build", "start": "next start", "test": "pnpm test:rust:unit && pnpm test:sync-e2e", diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..83b7140 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,158 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Get the root directory of the project +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +SYNC_DIR="$ROOT_DIR/donut-sync" + +# Track PIDs for cleanup +SYNC_PID="" +TAURI_PID="" +SHUTTING_DOWN=false + +cleanup() { + if [ "$SHUTTING_DOWN" = true ]; then + return + fi + SHUTTING_DOWN=true + + echo -e "\n${YELLOW}Shutting down services...${NC}" + + # Kill Tauri if running + if [ -n "$TAURI_PID" ] && kill -0 "$TAURI_PID" 2>/dev/null; then + echo -e "${BLUE}Stopping Tauri...${NC}" + kill "$TAURI_PID" 2>/dev/null || true + fi + + # Kill sync backend if running + if [ -n "$SYNC_PID" ] && kill -0 "$SYNC_PID" 2>/dev/null; then + echo -e "${BLUE}Stopping sync backend...${NC}" + kill "$SYNC_PID" 2>/dev/null || true + fi + + # Stop MinIO container + echo -e "${BLUE}Stopping MinIO container...${NC}" + cd "$SYNC_DIR" && docker compose down 2>/dev/null || true + + # Wait for processes to finish + wait 2>/dev/null || true + + echo -e "${GREEN}Cleanup complete.${NC}" +} + +trap cleanup EXIT INT TERM + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE} Donut Browser Development Environment${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" + +# Check prerequisites +echo -e "${YELLOW}Checking prerequisites...${NC}" + +if ! command -v docker &> /dev/null; then + echo -e "${RED}Error: docker is not installed${NC}" + exit 1 +fi + +if ! command -v pnpm &> /dev/null; then + echo -e "${RED}Error: pnpm is not installed${NC}" + exit 1 +fi + +echo -e "${GREEN}Prerequisites OK${NC}" +echo "" + +# Start MinIO container +echo -e "${YELLOW}Starting MinIO (S3) container...${NC}" +cd "$SYNC_DIR" +docker compose up -d + +# Wait for MinIO to be healthy +echo -e "${YELLOW}Waiting for MinIO to be healthy...${NC}" +MAX_RETRIES=30 +RETRY_COUNT=0 +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -sf http://localhost:8987/minio/health/live > /dev/null 2>&1; then + echo -e "${GREEN}MinIO is ready!${NC}" + break + fi + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo -e "${RED}MinIO failed to start within timeout${NC}" + exit 1 + fi + sleep 1 +done +echo "" + +# Install sync backend dependencies if needed +if [ ! -d "$SYNC_DIR/node_modules" ]; then + echo -e "${YELLOW}Installing sync backend dependencies...${NC}" + cd "$SYNC_DIR" && pnpm install +fi + +# Start sync backend in background +echo -e "${YELLOW}Starting sync backend...${NC}" +cd "$SYNC_DIR" +pnpm start:dev & +SYNC_PID=$! + +# Wait for sync backend to be ready +echo -e "${YELLOW}Waiting for sync backend to be ready...${NC}" +MAX_RETRIES=60 +RETRY_COUNT=0 +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -sf http://localhost:12342/health > /dev/null 2>&1; then + echo -e "${GREEN}Sync backend is ready!${NC}" + break + fi + # Check if process is still running + if ! kill -0 "$SYNC_PID" 2>/dev/null; then + echo -e "${RED}Sync backend process died${NC}" + exit 1 + fi + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo -e "${RED}Sync backend failed to start within timeout${NC}" + exit 1 + fi + sleep 1 +done +echo "" + +# Start Tauri app in background +echo -e "${YELLOW}Starting Tauri development server...${NC}" +echo -e "${BLUE}Frontend: http://localhost:12341${NC}" +echo -e "${BLUE}Sync Backend: http://localhost:12342${NC}" +echo -e "${BLUE}MinIO Console: http://localhost:8988${NC}" +echo "" +cd "$ROOT_DIR" +pnpm tauri dev & +TAURI_PID=$! + +# Monitor all processes - exit if any dies +echo -e "${YELLOW}Monitoring processes (Ctrl+C to stop all)...${NC}" +while true; do + # Check if sync backend died + if ! kill -0 "$SYNC_PID" 2>/dev/null; then + echo -e "${RED}Sync backend crashed!${NC}" + exit 1 + fi + + # Check if Tauri died + if ! kill -0 "$TAURI_PID" 2>/dev/null; then + echo -e "${RED}Tauri exited!${NC}" + exit 1 + fi + + sleep 2 +done diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f6f8033..82eaa6f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -54,6 +54,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -687,6 +699,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + [[package]] name = "bzip2" version = "0.6.1" @@ -696,6 +717,16 @@ dependencies = [ "libbz2-rs-sys", ] +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cab" version = "0.6.0" @@ -1012,7 +1043,7 @@ dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1269,7 +1300,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1353,7 +1384,7 @@ dependencies = [ "axum", "base64 0.22.1", "blake3", - "bzip2", + "bzip2 0.6.1", "chrono", "clap", "core-foundation 0.10.1", @@ -1369,15 +1400,21 @@ dependencies = [ "libc", "log", "lzma-rs", + "maxminddb", "mime_guess", "msi-extract", "objc2", "objc2-app-kit", "once_cell", + "playwright", + "quick-xml 0.37.5", "rand 0.9.2", + "regex-lite", "reqwest 0.13.1", + "rusqlite", "serde", "serde_json", + "serde_yaml", "serial_test", "sysinfo", "tar", @@ -1392,6 +1429,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tempfile", + "thiserror 1.0.69", "tokio", "tower", "tower-http", @@ -1403,7 +1441,7 @@ dependencies = [ "windows 0.62.2", "winreg", "wiremock", - "zip", + "zip 7.0.0", ] [[package]] @@ -1448,6 +1486,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "3.0.6" @@ -1551,7 +1595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1575,6 +1619,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1644,6 +1700,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1651,7 +1716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1665,6 +1730,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2156,7 +2227,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", ] [[package]] @@ -2164,6 +2235,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] [[package]] name = "hashbrown" @@ -2171,6 +2245,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2306,6 +2389,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -2522,6 +2621,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + [[package]] name = "iri-string" version = "0.7.10" @@ -2557,6 +2665,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -2760,6 +2877,17 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-rs-sys" version = "0.5.5" @@ -2825,6 +2953,17 @@ dependencies = [ "sha2", ] +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "lzxd" version = "0.2.6" @@ -2884,6 +3023,18 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maxminddb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c" +dependencies = [ + "ipnetwork", + "log", + "memchr", + "serde", +] + [[package]] name = "memchr" version = "2.7.6" @@ -2981,6 +3132,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3353,12 +3521,56 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3392,7 +3604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -3651,6 +3863,29 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "playwright" +version = "0.0.23" +source = "git+https://github.com/sctg-development/playwright-rust?branch=master#77d7a9729bc6c45b899a61eb4fb84adf075315e2" +dependencies = [ + "base64 0.22.1", + "chrono", + "dirs", + "futures", + "itertools", + "log", + "paste", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_with", + "strong", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "zip 2.4.2", +] + [[package]] name = "plist" version = "1.8.0" @@ -3659,7 +3894,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.12.1", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3842,6 +4077,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -3904,7 +4149,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4116,6 +4361,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + [[package]] name = "regex-syntax" version = "0.8.8" @@ -4139,22 +4390,31 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", + "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-util", "tower", "tower-http", @@ -4277,6 +4537,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -4328,7 +4602,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4351,10 +4625,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.0", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] @@ -4382,10 +4656,10 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4520,6 +4794,19 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -4720,6 +5007,19 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.12.1", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial_test" version = "3.2.0" @@ -4978,6 +5278,15 @@ dependencies = [ "quote", ] +[[package]] +name = "strong" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cbe0fc7652d95bcd84f61cd036181b395f329ef45b25169b69a42f72cb6975f" +dependencies = [ + "serde", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5564,7 +5873,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5714,6 +6023,16 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5724,6 +6043,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -6041,6 +6372,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -6156,6 +6493,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -6344,7 +6687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.38.4", "quote", ] @@ -6490,7 +6833,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7123,6 +7466,15 @@ dependencies = [ "rustix", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.1" @@ -7302,6 +7654,36 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2 0.5.2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.12.1", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.17", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zip" version = "7.0.0" @@ -7310,7 +7692,7 @@ checksum = "bdd8a47718a4ee5fe78e07667cd36f3de80e7c2bfe727c7074245ffc7303c037" dependencies = [ "aes", "arbitrary", - "bzip2", + "bzip2 0.6.1", "constant_time_eq", "crc32fast", "deflate64", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0f896c3..0072abf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -79,6 +79,16 @@ http-body-util = "0.1" clap = { version = "4", features = ["derive"] } async-socks5 = "0.6" +# Camoufox/Playwright integration +playwright = { git = "https://github.com/sctg-development/playwright-rust", branch = "master" } +rusqlite = { version = "0.32", features = ["bundled"] } +serde_yaml = "0.9" +thiserror = "1.0" +regex-lite = "0.1" +tempfile = "3" +maxminddb = "0.24" +quick-xml = { version = "0.37", features = ["serialize"] } + [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 8c9359e..65aaf82 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -75,23 +75,14 @@ fn external_binaries_exist() -> bool { let binaries_dir = PathBuf::from(&manifest_dir).join("binaries"); - // Check for both required external binaries - let nodecar_name = if target.contains("windows") { - format!("nodecar-{}.exe", target) - } else { - format!("nodecar-{}", target) - }; - + // Check for required external binaries let donut_proxy_name = if target.contains("windows") { format!("donut-proxy-{}.exe", target) } else { format!("donut-proxy-{}", target) }; - let nodecar_exists = binaries_dir.join(&nodecar_name).exists(); - let donut_proxy_exists = binaries_dir.join(&donut_proxy_name).exists(); - - nodecar_exists && donut_proxy_exists + binaries_dir.join(&donut_proxy_name).exists() } fn ensure_dist_folder_exists() { diff --git a/src-tauri/copy-proxy-binary.sh b/src-tauri/copy-proxy-binary.sh index ae1d046..53e3e68 100755 --- a/src-tauri/copy-proxy-binary.sh +++ b/src-tauri/copy-proxy-binary.sh @@ -32,7 +32,7 @@ fi SOURCE="$SRC_DIR/$BIN_NAME" DEST_DIR="$MANIFEST_DIR/binaries" -# Tauri expects the format: donut-proxy-{target} with hyphens (same as nodecar) +# Tauri expects the format: donut-proxy-{target} with hyphens DEST_NAME="donut-proxy-$TARGET" if [[ "$TARGET" == *"windows"* ]]; then DEST_NAME="$DEST_NAME.exe" diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index b08cd00..0b8accf 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -98,7 +98,7 @@ impl BrowserRunner { ); } - // Handle camoufox profiles using nodecar launcher + // Handle Camoufox profiles using CamoufoxManager if profile.browser == "camoufox" { // Get or create camoufox config let mut camoufox_config = profile.camoufox_config.clone().unwrap_or_else(|| { @@ -205,14 +205,11 @@ impl BrowserRunner { ); } - // Use the nodecar camoufox launcher - log::info!( - "Launching Camoufox via nodecar for profile: {}", - profile.name - ); + // Launch Camoufox browser + log::info!("Launching Camoufox for profile: {}", profile.name); let camoufox_result = self .camoufox_manager - .launch_camoufox_profile_nodecar( + .launch_camoufox_profile( app_handle.clone(), updated_profile.clone(), camoufox_config, @@ -220,7 +217,7 @@ impl BrowserRunner { ) .await .map_err(|e| -> Box { - format!("Failed to launch camoufox via nodecar: {e}").into() + format!("Failed to launch Camoufox: {e}").into() })?; // For server-based Camoufox, we use the process_id @@ -547,7 +544,7 @@ impl BrowserRunner { url: &str, _internal_proxy_settings: Option<&ProxySettings>, ) -> Result<(), Box> { - // Handle camoufox profiles using nodecar launcher + // Handle Camoufox profiles using CamoufoxManager if profile.browser == "camoufox" { // Get the profile path based on the UUID let profiles_dir = self.profile_manager.get_profiles_dir(); @@ -657,7 +654,7 @@ impl BrowserRunner { return Err("Unsupported platform".into()); } BrowserType::Camoufox => { - // Camoufox uses nodecar for launching, URL opening is handled differently + // Camoufox URL opening is handled differently Err("URL opening in existing Camoufox instance is not supported".into()) } BrowserType::Chromium | BrowserType::Brave => { @@ -941,7 +938,7 @@ impl BrowserRunner { app_handle: tauri::AppHandle, profile: &BrowserProfile, ) -> Result<(), Box> { - // Handle camoufox profiles using nodecar launcher + // Handle Camoufox profiles using CamoufoxManager if profile.browser == "camoufox" { // Search by profile path to find the running Camoufox instance let profiles_dir = self.profile_manager.get_profiles_dir(); diff --git a/src-tauri/src/camoufox/config.rs b/src-tauri/src/camoufox/config.rs new file mode 100644 index 0000000..4825e66 --- /dev/null +++ b/src-tauri/src/camoufox/config.rs @@ -0,0 +1,608 @@ +//! Camoufox configuration builder. +//! +//! Converts fingerprints to Camoufox configuration format and builds launch options. + +use rand::Rng; +use serde_yaml; +use std::collections::HashMap; +use std::path::Path; + +use crate::camoufox::data; +use crate::camoufox::env_vars; +use crate::camoufox::fingerprint::types::*; +use crate::camoufox::fonts; +use crate::camoufox::geolocation; +use crate::camoufox::webgl; + +/// Browserforge mapping from YAML. +type BrowserforgeMapping = HashMap; + +/// Load the browserforge mapping from embedded YAML. +fn load_browserforge_mapping() -> BrowserforgeMapping { + serde_yaml::from_str(data::BROWSERFORGE_YML).unwrap_or_default() +} + +/// Convert a fingerprint to Camoufox configuration. +pub fn from_browserforge( + fingerprint: &Fingerprint, + ff_version: Option, +) -> HashMap { + let mapping = load_browserforge_mapping(); + let mut config = HashMap::new(); + + // Convert fingerprint to a JSON value for easier traversal + let fp_json = serde_json::to_value(fingerprint).unwrap_or_default(); + + // Apply mappings recursively + cast_to_properties(&mut config, &mapping, &fp_json, ff_version); + + // Handle window.screenX and window.screenY + handle_screen_xy(&mut config, &fingerprint.screen); + + config +} + +/// Recursively cast fingerprint properties to Camoufox config format. +fn cast_to_properties( + config: &mut HashMap, + mapping: &BrowserforgeMapping, + fingerprint: &serde_json::Value, + ff_version: Option, +) { + if let serde_json::Value::Object(fp_obj) = fingerprint { + for (key, mapping_value) in mapping { + let fp_value = fp_obj.get(key); + + match mapping_value { + serde_yaml::Value::String(target_key) => { + if let Some(value) = fp_value { + let mut final_value = value.clone(); + + // Handle negative screen values + if target_key.starts_with("screen.") { + if let Some(num) = final_value.as_i64() { + if num < 0 { + final_value = serde_json::json!(0); + } + } + } + + // Replace Firefox version in user agent strings + if let (Some(version), Some(s)) = (ff_version, final_value.as_str()) { + let replaced = replace_ff_version(s, version); + final_value = serde_json::json!(replaced); + } + + config.insert(target_key.clone(), final_value); + } + } + serde_yaml::Value::Mapping(nested_mapping) => { + if let Some(nested_fp) = fp_value { + let nested: BrowserforgeMapping = nested_mapping + .iter() + .filter_map(|(k, v)| k.as_str().map(|ks| (ks.to_string(), v.clone()))) + .collect(); + cast_to_properties(config, &nested, nested_fp, ff_version); + } + } + _ => {} + } + } + } +} + +/// Replace Firefox version in user agent and related strings. +fn replace_ff_version(s: &str, version: u32) -> String { + // Match patterns like "135.0" (Firefox version) and replace with new version + let re = regex_lite::Regex::new(r"(?, screen: &ScreenFingerprint) { + if config.contains_key("window.screenY") { + return; + } + + let screen_x = screen.screen_x; + if screen_x == 0 { + config.insert("window.screenX".to_string(), serde_json::json!(0)); + config.insert("window.screenY".to_string(), serde_json::json!(0)); + return; + } + + if (-50..=50).contains(&screen_x) { + config.insert("window.screenY".to_string(), serde_json::json!(screen_x)); + return; + } + + let screen_y = screen.avail_height as i32 - screen.outer_height as i32; + let mut rng = rand::rng(); + + let y = if screen_y == 0 { + 0 + } else if screen_y > 0 { + rng.random_range(0..=screen_y) + } else { + rng.random_range(screen_y..=0) + }; + + config.insert("window.screenY".to_string(), serde_json::json!(y)); +} + +/// GeoIP option - can be an IP address string or auto-detect. +#[derive(Debug, Clone)] +pub enum GeoIPOption { + /// Auto-detect IP (fetch public IP, optionally through proxy) + Auto, + /// Use a specific IP address + IP(String), +} + +/// Configuration builder for Camoufox launch. +#[derive(Debug, Clone)] +pub struct CamoufoxConfigBuilder { + fingerprint: Option, + operating_system: Option, + screen_constraints: Option, + block_images: bool, + block_webrtc: bool, + block_webgl: bool, + custom_fonts: Option>, + custom_fonts_only: bool, + firefox_prefs: HashMap, + proxy: Option, + headless: bool, + ff_version: Option, + extra_config: HashMap, + geoip: Option, +} + +/// Proxy configuration. +#[derive(Debug, Clone)] +pub struct ProxyConfig { + pub server: String, + pub username: Option, + pub password: Option, + pub bypass: Option, +} + +impl ProxyConfig { + /// Parse a proxy URL string into ProxyConfig. + /// Supports formats like: + /// - "http://host:port" + /// - "http://user:pass@host:port" + /// - "socks5://user:pass@host:port" + pub fn from_url(url: &str) -> Result { + let parsed = url::Url::parse(url).map_err(|e| ConfigError::InvalidProxy(e.to_string()))?; + + let host = parsed + .host_str() + .ok_or_else(|| ConfigError::InvalidProxy("Missing host".to_string()))?; + + let port = parsed.port().unwrap_or(8080); + let scheme = parsed.scheme(); + + let server = format!("{scheme}://{host}:{port}"); + + let username = if !parsed.username().is_empty() { + Some(parsed.username().to_string()) + } else { + None + }; + + let password = parsed.password().map(String::from); + + Ok(Self { + server, + username, + password, + bypass: None, + }) + } +} + +impl Default for CamoufoxConfigBuilder { + fn default() -> Self { + Self::new() + } +} + +impl CamoufoxConfigBuilder { + pub fn new() -> Self { + Self { + fingerprint: None, + operating_system: None, + screen_constraints: None, + block_images: false, + block_webrtc: false, + block_webgl: false, + custom_fonts: None, + custom_fonts_only: false, + firefox_prefs: HashMap::new(), + proxy: None, + headless: false, + ff_version: None, + extra_config: HashMap::new(), + geoip: None, + } + } + + pub fn fingerprint(mut self, fp: Fingerprint) -> Self { + self.fingerprint = Some(fp); + self + } + + pub fn operating_system(mut self, os: &str) -> Self { + self.operating_system = Some(os.to_string()); + self + } + + pub fn screen_constraints(mut self, constraints: ScreenConstraints) -> Self { + self.screen_constraints = Some(constraints); + self + } + + pub fn block_images(mut self, block: bool) -> Self { + self.block_images = block; + self + } + + pub fn block_webrtc(mut self, block: bool) -> Self { + self.block_webrtc = block; + self + } + + pub fn block_webgl(mut self, block: bool) -> Self { + self.block_webgl = block; + self + } + + pub fn custom_fonts(mut self, fonts: Vec) -> Self { + self.custom_fonts = Some(fonts); + self + } + + pub fn custom_fonts_only(mut self, only: bool) -> Self { + self.custom_fonts_only = only; + self + } + + pub fn firefox_pref>(mut self, key: &str, value: V) -> Self { + self.firefox_prefs.insert(key.to_string(), value.into()); + self + } + + pub fn proxy(mut self, proxy: ProxyConfig) -> Self { + self.proxy = Some(proxy); + self + } + + pub fn headless(mut self, headless: bool) -> Self { + self.headless = headless; + self + } + + pub fn ff_version(mut self, version: u32) -> Self { + self.ff_version = Some(version); + self + } + + pub fn extra_config>(mut self, key: &str, value: V) -> Self { + self.extra_config.insert(key.to_string(), value.into()); + self + } + + /// Set GeoIP option for geolocation-based fingerprinting. + /// Use `GeoIPOption::Auto` to auto-detect public IP (optionally through proxy). + /// Use `GeoIPOption::IP(ip_string)` to use a specific IP address. + pub fn geoip(mut self, option: GeoIPOption) -> Self { + self.geoip = Some(option); + self + } + + /// Build the complete Camoufox launch configuration. + pub fn build(self) -> Result { + // Generate or use provided fingerprint + let fingerprint = if let Some(fp) = self.fingerprint { + fp + } else { + let generator = crate::camoufox::fingerprint::FingerprintGenerator::new()?; + let options = FingerprintOptions { + operating_system: self.operating_system.clone(), + browsers: Some(vec!["firefox".to_string()]), + devices: Some(vec!["desktop".to_string()]), + screen: self.screen_constraints, + ..Default::default() + }; + generator.get_fingerprint(&options)?.fingerprint + }; + + // Determine target OS from user agent + let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent); + + // Convert fingerprint to config + let mut config = from_browserforge(&fingerprint, self.ff_version); + + // Add random window history length + let mut rng = rand::rng(); + config.insert( + "window.history.length".to_string(), + serde_json::json!(rng.random_range(1..=5)), + ); + + // Add fonts + if !self.custom_fonts_only { + let system_fonts = fonts::get_fonts_for_os(target_os); + let fonts = if let Some(custom) = &self.custom_fonts { + let mut all_fonts = system_fonts; + for font in custom { + if !all_fonts.contains(font) { + all_fonts.push(font.clone()); + } + } + all_fonts + } else { + system_fonts + }; + config.insert("fonts".to_string(), serde_json::json!(fonts)); + } else if let Some(custom) = &self.custom_fonts { + config.insert("fonts".to_string(), serde_json::json!(custom)); + } + + // Add font spacing seed + config.insert( + "fonts:spacing_seed".to_string(), + serde_json::json!(rng.random_range(0..1_073_741_824u32)), + ); + + // Build Firefox preferences + let mut firefox_prefs = self.firefox_prefs; + + if self.block_images { + firefox_prefs.insert( + "permissions.default.image".to_string(), + serde_json::json!(2), + ); + } + + if self.block_webrtc { + firefox_prefs.insert( + "media.peerconnection.enabled".to_string(), + serde_json::json!(false), + ); + } + + if self.block_webgl { + firefox_prefs.insert("webgl.disabled".to_string(), serde_json::json!(true)); + } else { + // Sample and add WebGL configuration + match webgl::sample_webgl(target_os, None, None) { + Ok(webgl_data) => { + for (key, value) in webgl_data.config { + config.insert(key, value); + } + firefox_prefs.insert("webgl.force-enabled".to_string(), serde_json::json!(true)); + } + Err(e) => { + log::warn!("Failed to sample WebGL config: {}", e); + } + } + } + + // Canvas anti-fingerprinting + config.insert( + "canvas:aaOffset".to_string(), + serde_json::json!(rng.random_range(-50..=50)), + ); + config.insert("canvas:aaCapOffset".to_string(), serde_json::json!(true)); + + // Add extra config (user-provided) + for (key, value) in self.extra_config { + config.insert(key, value); + } + + // Hardcoded Camoufox settings (cannot be overridden) + // Disable theming to prevent fingerprinting via browser theme + config.insert("disableTheming".to_string(), serde_json::json!(true)); + // Hide cursor in headless mode + config.insert("showcursor".to_string(), serde_json::json!(false)); + + Ok(CamoufoxLaunchConfig { + fingerprint_config: config, + firefox_prefs, + proxy: self.proxy, + headless: self.headless, + target_os: target_os.to_string(), + }) + } + + /// Build the complete Camoufox launch configuration with async geolocation support. + /// This method should be used when geoip option is set to Auto. + pub async fn build_async(self) -> Result { + // Get proxy URL for IP detection if set + let proxy_url = self.proxy.as_ref().map(|p| p.server.clone()); + let geoip_option = self.geoip.clone(); + let block_webrtc = self.block_webrtc; + + // Build base config first + let mut launch_config = self.build()?; + + // Handle geolocation if geoip option is set + if let Some(geoip) = geoip_option { + let ip = match geoip { + GeoIPOption::Auto => { + // Fetch public IP, optionally through proxy + geolocation::fetch_public_ip(proxy_url.as_deref()).await? + } + GeoIPOption::IP(ip_str) => { + if !geolocation::validate_ip(&ip_str) { + return Err(ConfigError::Geolocation( + geolocation::GeolocationError::InvalidIP(ip_str), + )); + } + ip_str + } + }; + + // Get geolocation from IP + match geolocation::get_geolocation(&ip) { + Ok(geo) => { + // Add geolocation config + for (key, value) in geo.as_config() { + launch_config.fingerprint_config.insert(key, value); + } + + // Add WebRTC IP spoofing if not blocked + if !block_webrtc { + if geolocation::is_ipv4(&ip) { + launch_config + .fingerprint_config + .insert("webrtc:ipv4".to_string(), serde_json::json!(ip)); + } else if geolocation::is_ipv6(&ip) { + launch_config + .fingerprint_config + .insert("webrtc:ipv6".to_string(), serde_json::json!(ip)); + } + } + + log::info!( + "Applied geolocation from IP {}: {} ({})", + ip, + geo.locale.as_string(), + geo.timezone + ); + } + Err(e) => { + log::warn!("Failed to get geolocation for IP {}: {}", ip, e); + // Continue without geolocation rather than failing + } + } + } + + Ok(launch_config) + } +} + +/// Complete Camoufox launch configuration. +#[derive(Debug, Clone)] +pub struct CamoufoxLaunchConfig { + pub fingerprint_config: HashMap, + pub firefox_prefs: HashMap, + pub proxy: Option, + pub headless: bool, + pub target_os: String, +} + +impl CamoufoxLaunchConfig { + /// Get environment variables for launching Camoufox. + pub fn get_env_vars(&self) -> Result, serde_json::Error> { + env_vars::config_to_env_vars(&self.fingerprint_config) + } + + /// Get the config as JSON string. + pub fn config_json(&self) -> Result { + serde_json::to_string(&self.fingerprint_config) + } +} + +/// Error type for configuration operations. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("Fingerprint generation error: {0}")] + Fingerprint(#[from] crate::camoufox::fingerprint::FingerprintError), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("WebGL error: {0}")] + WebGL(#[from] webgl::WebGLError), + + #[error("Invalid proxy configuration: {0}")] + InvalidProxy(String), + + #[error("Geolocation error: {0}")] + Geolocation(#[from] crate::camoufox::geolocation::GeolocationError), +} + +/// Get Firefox version from executable path. +pub fn get_firefox_version(executable_path: &Path) -> Option { + // Try to read version.json from the same directory + let version_path = executable_path.parent()?.join("version.json"); + + if let Ok(content) = std::fs::read_to_string(&version_path) { + if let Ok(json) = serde_json::from_str::(&content) { + if let Some(version_str) = json.get("version").and_then(|v| v.as_str()) { + // Parse major version from "135.0" or similar + let major: u32 = version_str.split('.').next()?.parse().ok()?; + return Some(major); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_builder() { + let config = CamoufoxConfigBuilder::new() + .operating_system("windows") + .block_images(true) + .build(); + + assert!(config.is_ok()); + let config = config.unwrap(); + assert!(config + .firefox_prefs + .contains_key("permissions.default.image")); + } + + #[test] + fn test_replace_ff_version() { + let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0"; + let replaced = replace_ff_version(ua, 140); + assert!(replaced.contains("140.0")); + } + + #[test] + fn test_from_browserforge() { + let fingerprint = Fingerprint { + screen: ScreenFingerprint { + width: 1920, + height: 1080, + avail_width: 1920, + avail_height: 1040, + color_depth: 24, + pixel_depth: 24, + inner_width: 1903, + inner_height: 969, + outer_width: 1920, + outer_height: 1040, + ..Default::default() + }, + navigator: NavigatorFingerprint { + user_agent: "Mozilla/5.0 Firefox/135.0".to_string(), + platform: "Win32".to_string(), + language: "en-US".to_string(), + languages: vec!["en-US".to_string()], + hardware_concurrency: 8, + ..Default::default() + }, + ..Default::default() + }; + + let config = from_browserforge(&fingerprint, Some(140)); + + assert!(config.contains_key("navigator.userAgent")); + assert!(config.contains_key("screen.width")); + } +} diff --git a/src-tauri/src/camoufox/data/browser-helper-file.json b/src-tauri/src/camoufox/data/browser-helper-file.json new file mode 100644 index 0000000..49c114a --- /dev/null +++ b/src-tauri/src/camoufox/data/browser-helper-file.json @@ -0,0 +1,120 @@ +[ + "chrome/143.0.0.0|2", + "safari/18.3.1|2", + "chrome/101.0.4951.54|2", + "chrome/139.0.0.0|2", + "safari/16.6|2", + "safari/26.2|2", + "safari/18.6|2", + "safari/26.1|2", + "chrome/142.0.0.0|2", + "chrome/141.0.0.0|2", + "safari/18.7.3|2", + "edge/143.0.0.0|2", + "safari/18.4|2", + "safari/17.3.1|2", + "chrome/135.0.0.0|2", + "safari/18.5|2", + "safari/18.7.2|2", + "chrome/143.0.0.0|1", + "chrome/128.0.0.0|2", + "chrome/131.0.0.0|2", + "safari/26.3|2", + "safari/26.0.1|2", + "chrome/114.0.0.0|2", + "safari/18.1|2", + "firefox/147.0|2", + "safari/17.5|2", + "chrome/140.0.0.0|2", + "safari/16.6.1|2", + "firefox/146.0|2", + "chrome/124.0.0.0|1", + "chrome/34.0.1847.114|2", + "chrome/130.0.0.0|2", + "safari/15.6.7|2", + "chrome/144.0.0.0|2", + "safari/18.3|2", + "safari/16.4|2", + "chrome/141.0.7390.122|1", + "firefox/140.0|2", + "chrome/138.0.0.0|2", + "firefox/135.0|2", + "safari/17.6|2", + "chrome/132.0.0.0|2", + "chrome/109.0.0.0|2", + "chrome/92.0.4515.131|2", + "chrome/136.0.0.0|2", + "edge/142.0.0.0|2", + "chrome/125.0.0.0|2", + "safari/17.8|2", + "edge/143.0.0.0|1", + "chrome/123.0.0.0|2", + "chrome/137.0.0.0|2", + "chrome/129.0.0.0|2", + "chrome/126.0.0.0|2", + "safari/26.0|2", + "chrome/133.0.0.0|2", + "chrome/119.0.0.0|2", + "chrome/145.0.0.0|2", + "firefox/145.0|2", + "safari/17.3|2", + "safari/18.2|2", + "safari/16.5.2|2", + "safari/17.4|2", + "chrome/120.0.0.0|2", + "chrome/116.0.0.0|2", + "firefox/141.0|2", + "safari/17.4.1|2", + "chrome/134.0.0.0|2", + "safari/15.4|2", + "safari/18.1.1|2", + "edge/144.0.0.0|2", + "firefox/144.0|2", + "safari/16.3|2", + "safari/13.0.3|2", + "chrome/131.0.6778.33|2", + "edge/145.0.0.0|2", + "edge/139.0.0.0|2", + "safari/17.1|2", + "chrome/133.0.0.0|1", + "chrome/121.0.0.0|2", + "chrome/124.0.0.0|2", + "chrome/127.0.0.0|2", + "chrome/122.0.6261.95|2", + "chrome/91.0.4450.0|2", + "edge/134.0.0.0|2", + "chrome/134.0.6998.179|2", + "chrome/122.0.0.0|2", + "firefox/128.0|2", + "chrome/142.0.0.0|1", + "safari/18.7|2", + "safari/17.8.1|2", + "firefox/115.0|2", + "safari/17.2|2", + "chrome/117.0.0.0|2", + "safari/18.0.1|2", + "chrome/139.0.7258.5|2", + "edge/140.0.0.0|2", + "safari/16.5|2", + "safari/18.6.2|2", + "firefox/136.0|2", + "safari/17.2.1|2", + "safari/18.0|2", + "safari/15.6.1|2", + "safari/26.2|1", + "safari/17.1.2|2", + "safari/17.7|2", + "safari/16.2|2", + "edge/122.0.0.0|2", + "chrome/139.0.0.0|1", + "safari/17.0|2", + "firefox/139.0|2", + "chrome/101.0.9316.173|2", + "chrome/101.0.4951.64|2", + "chrome/141.0.0.0|1", + "safari/15.5|2", + "safari/18.6|1", + "chrome/112.0.0.0|2", + "edge/135.0.0.0|2", + "chrome/140.0.0.0|1" +] diff --git a/src-tauri/src/camoufox/data/browserforge.yml b/src-tauri/src/camoufox/data/browserforge.yml new file mode 100644 index 0000000..7f7df90 --- /dev/null +++ b/src-tauri/src/camoufox/data/browserforge.yml @@ -0,0 +1,68 @@ +# Mappings of Browserforge fingerprints to Camoufox config properties. + +navigator: + # Note: Browserforge tends to have outdated UAs. + # The version will be replaced in Camoufox. + userAgent: navigator.userAgent + # userAgentData not in Firefox + doNotTrack: navigator.doNotTrack + appCodeName: navigator.appCodeName + appName: navigator.appName + appVersion: navigator.appVersion + oscpu: navigator.oscpu + # webdriver is always True + # Locale is now implemented separately: + # language: navigator.language + # languages: navigator.languages + platform: navigator.platform + # deviceMemory not in Firefox + hardwareConcurrency: navigator.hardwareConcurrency + product: navigator.product + # Never override productSub #105 + # productSub: navigator.productSub + # vendor is not necessary + # vendorSub is not necessary + maxTouchPoints: navigator.maxTouchPoints + extraProperties: + # Note: Changing pdfViewerEnabled is not recommended. This will be kept to True. + globalPrivacyControl: navigator.globalPrivacyControl + +screen: + # hasHDR is not implemented in Camoufox + availLeft: screen.availLeft + availTop: screen.availTop + availWidth: screen.availWidth + availHeight: screen.availHeight + height: screen.height + width: screen.width + colorDepth: screen.colorDepth + pixelDepth: screen.pixelDepth + # devicePixelRatio is not recommended. Any value other than 1.0 is suspicious. + pageXOffset: screen.pageXOffset + pageYOffset: screen.pageYOffset + outerHeight: window.outerHeight + outerWidth: window.outerWidth + innerHeight: window.innerHeight + innerWidth: window.innerWidth + screenX: window.screenX + screenY: window.screenY + # Tends to generate out of bounds (network inconsistencies): + # clientWidth: document.body.clientWidth + # clientHeight: document.body.clientHeight + +# videoCard: +# renderer: webgl:renderer +# vendor: webgl:vendor + +headers: + # headers.User-Agent is redundant with navigator.userAgent + # headers.Accept-Language is redundant with locale:* + Accept-Encoding: headers.Accept-Encoding + +battery: + charging: battery:charging + chargingTime: battery:chargingTime + dischargingTime: battery:dischargingTime + +# Unsupported: videoCodecs, audioCodecs, pluginsData, multimediaDevices +# Fonts are listed through the launcher. diff --git a/src-tauri/src/camoufox/data/fingerprint-network-definition.zip b/src-tauri/src/camoufox/data/fingerprint-network-definition.zip new file mode 100644 index 0000000..06000f9 Binary files /dev/null and b/src-tauri/src/camoufox/data/fingerprint-network-definition.zip differ diff --git a/src-tauri/src/camoufox/data/fonts.json b/src-tauri/src/camoufox/data/fonts.json new file mode 100644 index 0000000..0f16348 --- /dev/null +++ b/src-tauri/src/camoufox/data/fonts.json @@ -0,0 +1,822 @@ +{ + "win": [ + "Arial", + "Arial Black", + "Bahnschrift", + "Calibri", + "Calibri Light", + "Cambria", + "Cambria Math", + "Candara", + "Candara Light", + "Comic Sans MS", + "Consolas", + "Constantia", + "Corbel", + "Corbel Light", + "Courier New", + "Ebrima", + "Franklin Gothic Medium", + "Gabriola", + "Gadugi", + "Georgia", + "HoloLens MDL2 Assets", + "Impact", + "Ink Free", + "Javanese Text", + "Leelawadee UI", + "Leelawadee UI Semilight", + "Lucida Console", + "Lucida Sans Unicode", + "MS Gothic", + "MS PGothic", + "MS UI Gothic", + "MV Boli", + "Malgun Gothic", + "Malgun Gothic Semilight", + "Marlett", + "Microsoft Himalaya", + "Microsoft JhengHei", + "Microsoft JhengHei Light", + "Microsoft JhengHei UI", + "Microsoft JhengHei UI Light", + "Microsoft New Tai Lue", + "Microsoft PhagsPa", + "Microsoft Sans Serif", + "Microsoft Tai Le", + "Microsoft YaHei", + "Microsoft YaHei Light", + "Microsoft YaHei UI", + "Microsoft YaHei UI Light", + "Microsoft Yi Baiti", + "MingLiU-ExtB", + "MingLiU_HKSCS-ExtB", + "Mongolian Baiti", + "Myanmar Text", + "NSimSun", + "Nirmala UI", + "Nirmala UI Semilight", + "PMingLiU-ExtB", + "Palatino Linotype", + "Segoe Fluent Icons", + "Segoe MDL2 Assets", + "Segoe Print", + "Segoe Script", + "Segoe UI", + "Segoe UI Black", + "Segoe UI Emoji", + "Segoe UI Historic", + "Segoe UI Light", + "Segoe UI Semibold", + "Segoe UI Semilight", + "Segoe UI Symbol", + "Segoe UI Variable", + "SimSun", + "SimSun-ExtB", + "Sitka", + "Sitka Text", + "Sylfaen", + "Symbol", + "Tahoma", + "Times New Roman", + "Trebuchet MS", + "Twemoji Mozilla", + "Verdana", + "Webdings", + "Wingdings", + "Yu Gothic", + "Yu Gothic Light", + "Yu Gothic Medium", + "Yu Gothic UI", + "Yu Gothic UI Light", + "Yu Gothic UI Semibold", + "Yu Gothic UI Semilight", + "\u5b8b\u4f53", + "\u5fae\u8edf\u6b63\u9ed1\u9ad4", + "\u5fae\u8edf\u6b63\u9ed1\u9ad4 Light", + "\u5fae\u8f6f\u96c5\u9ed1", + "\u5fae\u8f6f\u96c5\u9ed1 Light", + "\u65b0\u5b8b\u4f53", + "\u65b0\u7d30\u660e\u9ad4-ExtB", + "\u6e38\u30b4\u30b7\u30c3\u30af", + "\u6e38\u30b4\u30b7\u30c3\u30af Light", + "\u6e38\u30b4\u30b7\u30c3\u30af Medium", + "\u7d30\u660e\u9ad4-ExtB", + "\u7d30\u660e\u9ad4_HKSCS-ExtB", + "\ub9d1\uc740 \uace0\ub515", + "\ub9d1\uc740 \uace0\ub515 Semilight", + "\uff2d\uff33 \u30b4\u30b7\u30c3\u30af", + "\uff2d\uff33 \uff30\u30b4\u30b7\u30c3\u30af" + ], + "mac": [ + ".Al Bayan PUA", + ".Al Nile PUA", + ".Al Tarikh PUA", + ".Apple Color Emoji UI", + ".Apple SD Gothic NeoI", + ".Aqua Kana", + ".Aqua Kana Bold", + ".Aqua \u304b\u306a", + ".Aqua \u304b\u306a \u30dc\u30fc\u30eb\u30c9", + ".Arial Hebrew Desk Interface", + ".Baghdad PUA", + ".Beirut PUA", + ".Damascus PUA", + ".DecoType Naskh PUA", + ".Diwan Kufi PUA", + ".Farah PUA", + ".Geeza Pro Interface", + ".Geeza Pro PUA", + ".Helvetica LT MM", + ".Hiragino Kaku Gothic Interface", + ".Hiragino Sans GB Interface", + ".Keyboard", + ".KufiStandardGK PUA", + ".LastResort", + ".Lucida Grande UI", + ".Muna PUA", + ".Nadeem PUA", + ".New York", + ".Noto Nastaliq Urdu UI", + ".PingFang HK", + ".PingFang SC", + ".PingFang TC", + ".SF Arabic", + ".SF Arabic Rounded", + ".SF Compact", + ".SF Compact Rounded", + ".SF NS", + ".SF NS Mono", + ".SF NS Rounded", + ".Sana PUA", + ".Savoye LET CC.", + ".ThonburiUI", + ".ThonburiUIWatch", + ".\u82f9\u65b9-\u6e2f", + ".\u82f9\u65b9-\u7b80", + ".\u82f9\u65b9-\u7e41", + ".\u860b\u65b9-\u6e2f", + ".\u860b\u65b9-\u7c21", + ".\u860b\u65b9-\u7e41", + "Academy Engraved LET", + "Al Bayan", + "Al Nile", + "Al Tarikh", + "American Typewriter", + "Andale Mono", + "Apple Braille", + "Apple Chancery", + "Apple Color Emoji", + "Apple SD Gothic Neo", + "Apple SD \uc0b0\ub3cc\uace0\ub515 Neo", + "Apple Symbols", + "AppleGothic", + "AppleMyungjo", + "Arial", + "Arial Black", + "Arial Hebrew", + "Arial Hebrew Scholar", + "Arial Narrow", + "Arial Rounded MT Bold", + "Arial Unicode MS", + "Athelas", + "Avenir", + "Avenir Black", + "Avenir Black Oblique", + "Avenir Book", + "Avenir Heavy", + "Avenir Light", + "Avenir Medium", + "Avenir Next", + "Avenir Next Condensed", + "Avenir Next Condensed Demi Bold", + "Avenir Next Condensed Heavy", + "Avenir Next Condensed Medium", + "Avenir Next Condensed Ultra Light", + "Avenir Next Demi Bold", + "Avenir Next Heavy", + "Avenir Next Medium", + "Avenir Next Ultra Light", + "Ayuthaya", + "Baghdad", + "Bangla MN", + "Bangla Sangam MN", + "Baskerville", + "Beirut", + "Big Caslon", + "Bodoni 72", + "Bodoni 72 Oldstyle", + "Bodoni 72 Smallcaps", + "Bodoni Ornaments", + "Bradley Hand", + "Brush Script MT", + "Chalkboard", + "Chalkboard SE", + "Chalkduster", + "Charter", + "Charter Black", + "Cochin", + "Comic Sans MS", + "Copperplate", + "Corsiva Hebrew", + "Courier", + "Courier New", + "Czcionka systemowa", + "DIN Alternate", + "DIN Condensed", + "Damascus", + "DecoType Naskh", + "Devanagari MT", + "Devanagari Sangam MN", + "Didot", + "Diwan Kufi", + "Diwan Thuluth", + "Euphemia UCAS", + "Farah", + "Farisi", + "Font Sistem", + "Font de sistem", + "Font di sistema", + "Font sustava", + "Fonte do Sistema", + "Futura", + "GB18030 Bitmap", + "Galvji", + "Geeza Pro", + "Geneva", + "Georgia", + "Gill Sans", + "Grantha Sangam MN", + "Gujarati MT", + "Gujarati Sangam MN", + "Gurmukhi MN", + "Gurmukhi MT", + "Gurmukhi Sangam MN", + "Heiti SC", + "Heiti TC", + "Heiti-\uac04\uccb4", + "Heiti-\ubc88\uccb4", + "Helvetica", + "Helvetica Neue", + "Herculanum", + "Hiragino Kaku Gothic Pro", + "Hiragino Kaku Gothic Pro W3", + "Hiragino Kaku Gothic Pro W6", + "Hiragino Kaku Gothic ProN", + "Hiragino Kaku Gothic ProN W3", + "Hiragino Kaku Gothic ProN W6", + "Hiragino Kaku Gothic Std", + "Hiragino Kaku Gothic Std W8", + "Hiragino Kaku Gothic StdN", + "Hiragino Kaku Gothic StdN W8", + "Hiragino Maru Gothic Pro", + "Hiragino Maru Gothic Pro W4", + "Hiragino Maru Gothic ProN", + "Hiragino Maru Gothic ProN W4", + "Hiragino Mincho Pro", + "Hiragino Mincho Pro W3", + "Hiragino Mincho Pro W6", + "Hiragino Mincho ProN", + "Hiragino Mincho ProN W3", + "Hiragino Mincho ProN W6", + "Hiragino Sans", + "Hiragino Sans GB", + "Hiragino Sans GB W3", + "Hiragino Sans GB W6", + "Hiragino Sans W0", + "Hiragino Sans W1", + "Hiragino Sans W2", + "Hiragino Sans W3", + "Hiragino Sans W4", + "Hiragino Sans W5", + "Hiragino Sans W6", + "Hiragino Sans W7", + "Hiragino Sans W8", + "Hiragino Sans W9", + "Hoefler Text", + "Hoefler Text Ornaments", + "ITF Devanagari", + "ITF Devanagari Marathi", + "Impact", + "InaiMathi", + "Iowan Old Style", + "Iowan Old Style Black", + "J\u00e4rjestelm\u00e4fontti", + "Kailasa", + "Kannada MN", + "Kannada Sangam MN", + "Kefa", + "Khmer MN", + "Khmer Sangam MN", + "Kohinoor Bangla", + "Kohinoor Devanagari", + "Kohinoor Gujarati", + "Kohinoor Telugu", + "Kokonor", + "Krungthep", + "KufiStandardGK", + "Lao MN", + "Lao Sangam MN", + "Lucida Grande", + "Luminari", + "Malayalam MN", + "Malayalam Sangam MN", + "Marion", + "Marker Felt", + "Menlo", + "Microsoft Sans Serif", + "Mishafi", + "Mishafi Gold", + "Monaco", + "Mshtakan", + "Mukta Mahee", + "MuktaMahee Bold", + "MuktaMahee ExtraBold", + "MuktaMahee ExtraLight", + "MuktaMahee Light", + "MuktaMahee Medium", + "MuktaMahee Regular", + "MuktaMahee SemiBold", + "Muna", + "Myanmar MN", + "Myanmar Sangam MN", + "Nadeem", + "New Peninim MT", + "Noteworthy", + "Noto Nastaliq Urdu", + "Noto Sans Adlam", + "Noto Sans Armenian", + "Noto Sans Armenian Blk", + "Noto Sans Armenian ExtBd", + "Noto Sans Armenian ExtLt", + "Noto Sans Armenian Light", + "Noto Sans Armenian Med", + "Noto Sans Armenian SemBd", + "Noto Sans Armenian Thin", + "Noto Sans Avestan", + "Noto Sans Bamum", + "Noto Sans Bassa Vah", + "Noto Sans Batak", + "Noto Sans Bhaiksuki", + "Noto Sans Brahmi", + "Noto Sans Buginese", + "Noto Sans Buhid", + "Noto Sans CanAborig", + "Noto Sans Canadian Aboriginal", + "Noto Sans Carian", + "Noto Sans CaucAlban", + "Noto Sans Caucasian Albanian", + "Noto Sans Chakma", + "Noto Sans Cham", + "Noto Sans Coptic", + "Noto Sans Cuneiform", + "Noto Sans Cypriot", + "Noto Sans Duployan", + "Noto Sans EgyptHiero", + "Noto Sans Egyptian Hieroglyphs", + "Noto Sans Elbasan", + "Noto Sans Glagolitic", + "Noto Sans Gothic", + "Noto Sans Gunjala Gondi", + "Noto Sans Hanifi Rohingya", + "Noto Sans HanifiRohg", + "Noto Sans Hanunoo", + "Noto Sans Hatran", + "Noto Sans ImpAramaic", + "Noto Sans Imperial Aramaic", + "Noto Sans InsPahlavi", + "Noto Sans InsParthi", + "Noto Sans Inscriptional Pahlavi", + "Noto Sans Inscriptional Parthian", + "Noto Sans Javanese", + "Noto Sans Kaithi", + "Noto Sans Kannada", + "Noto Sans Kannada Black", + "Noto Sans Kannada ExtraBold", + "Noto Sans Kannada ExtraLight", + "Noto Sans Kannada Light", + "Noto Sans Kannada Medium", + "Noto Sans Kannada SemiBold", + "Noto Sans Kannada Thin", + "Noto Sans Kayah Li", + "Noto Sans Kharoshthi", + "Noto Sans Khojki", + "Noto Sans Khudawadi", + "Noto Sans Lepcha", + "Noto Sans Limbu", + "Noto Sans Linear A", + "Noto Sans Linear B", + "Noto Sans Lisu", + "Noto Sans Lycian", + "Noto Sans Lydian", + "Noto Sans Mahajani", + "Noto Sans Mandaic", + "Noto Sans Manichaean", + "Noto Sans Marchen", + "Noto Sans Masaram Gondi", + "Noto Sans Meetei Mayek", + "Noto Sans Mende Kikakui", + "Noto Sans Meroitic", + "Noto Sans Miao", + "Noto Sans Modi", + "Noto Sans Mongolian", + "Noto Sans Mro", + "Noto Sans Multani", + "Noto Sans Myanmar", + "Noto Sans Myanmar Blk", + "Noto Sans Myanmar ExtBd", + "Noto Sans Myanmar ExtLt", + "Noto Sans Myanmar Light", + "Noto Sans Myanmar Med", + "Noto Sans Myanmar SemBd", + "Noto Sans Myanmar Thin", + "Noto Sans NKo", + "Noto Sans Nabataean", + "Noto Sans New Tai Lue", + "Noto Sans Newa", + "Noto Sans Ol Chiki", + "Noto Sans Old Hungarian", + "Noto Sans Old Italic", + "Noto Sans Old North Arabian", + "Noto Sans Old Permic", + "Noto Sans Old Persian", + "Noto Sans Old South Arabian", + "Noto Sans Old Turkic", + "Noto Sans OldHung", + "Noto Sans OldNorArab", + "Noto Sans OldSouArab", + "Noto Sans Oriya", + "Noto Sans Osage", + "Noto Sans Osmanya", + "Noto Sans Pahawh Hmong", + "Noto Sans Palmyrene", + "Noto Sans Pau Cin Hau", + "Noto Sans PhagsPa", + "Noto Sans Phoenician", + "Noto Sans PsaPahlavi", + "Noto Sans Psalter Pahlavi", + "Noto Sans Rejang", + "Noto Sans Samaritan", + "Noto Sans Saurashtra", + "Noto Sans Sharada", + "Noto Sans Siddham", + "Noto Sans Sora Sompeng", + "Noto Sans SoraSomp", + "Noto Sans Sundanese", + "Noto Sans Syloti Nagri", + "Noto Sans Syriac", + "Noto Sans Tagalog", + "Noto Sans Tagbanwa", + "Noto Sans Tai Le", + "Noto Sans Tai Tham", + "Noto Sans Tai Viet", + "Noto Sans Takri", + "Noto Sans Thaana", + "Noto Sans Tifinagh", + "Noto Sans Tirhuta", + "Noto Sans Ugaritic", + "Noto Sans Vai", + "Noto Sans Wancho", + "Noto Sans Warang Citi", + "Noto Sans Yi", + "Noto Sans Zawgyi", + "Noto Sans Zawgyi Blk", + "Noto Sans Zawgyi ExtBd", + "Noto Sans Zawgyi ExtLt", + "Noto Sans Zawgyi Light", + "Noto Sans Zawgyi Med", + "Noto Sans Zawgyi SemBd", + "Noto Sans Zawgyi Thin", + "Noto Serif Ahom", + "Noto Serif Balinese", + "Noto Serif Hmong Nyiakeng", + "Noto Serif Myanmar", + "Noto Serif Myanmar Blk", + "Noto Serif Myanmar ExtBd", + "Noto Serif Myanmar ExtLt", + "Noto Serif Myanmar Light", + "Noto Serif Myanmar Med", + "Noto Serif Myanmar SemBd", + "Noto Serif Myanmar Thin", + "Noto Serif Yezidi", + "Optima", + "Oriya MN", + "Oriya Sangam MN", + "PT Mono", + "PT Sans", + "PT Sans Caption", + "PT Sans Narrow", + "PT Serif", + "PT Serif Caption", + "Palatino", + "Papyrus", + "Party LET", + "Phosphate", + "Ph\u00f4ng ch\u1eef H\u1ec7 th\u1ed1ng", + "PingFang HK", + "PingFang SC", + "PingFang TC", + "Plantagenet Cherokee", + "Police syst\u00e8me", + "Raanana", + "Rendszerbet\u0171t\u00edpus", + "Rockwell", + "STIX Two Math", + "STIX Two Text", + "STIXGeneral", + "STIXIntegralsD", + "STIXIntegralsSm", + "STIXIntegralsUp", + "STIXIntegralsUpD", + "STIXIntegralsUpSm", + "STIXNonUnicode", + "STIXSizeFiveSym", + "STIXSizeFourSym", + "STIXSizeOneSym", + "STIXSizeThreeSym", + "STIXSizeTwoSym", + "STIXVariants", + "STSong", + "Sana", + "Sathu", + "Savoye LET", + "Seravek", + "Seravek ExtraLight", + "Seravek Light", + "Seravek Medium", + "Shree Devanagari 714", + "SignPainter", + "SignPainter-HouseScript", + "Silom", + "Sinhala MN", + "Sinhala Sangam MN", + "Sistem Fontu", + "Skia", + "Snell Roundhand", + "Songti SC", + "Songti TC", + "Sukhumvit Set", + "Superclarendon", + "Symbol", + "Systeemlettertype", + "System Font", + "Systemschrift", + "Systemskrift", + "Systemtypsnitt", + "Syst\u00e9mov\u00e9 p\u00edsmo", + "Tahoma", + "Tamil MN", + "Tamil Sangam MN", + "Telugu MN", + "Telugu Sangam MN", + "Thonburi", + "Times", + "Times New Roman", + "Tipo de letra del sistema", + "Tipo de letra do sistema", + "Tipus de lletra del sistema", + "Trattatello", + "Trebuchet MS", + "Verdana", + "Waseem", + "Webdings", + "Wingdings", + "Wingdings 2", + "Wingdings 3", + "Zapf Dingbats", + "Zapfino", + "\u0393\u03c1\u03b1\u03bc\u03bc\u03b1\u03c4\u03bf\u03c3\u03b5\u03b9\u03c1\u03ac \u03c3\u03c5\u03c3\u03c4\u03ae\u03bc\u03b1\u03c4\u03bf\u03c2", + "\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u0438\u0439 \u0448\u0440\u0438\u0444\u0442", + "\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u044b\u0439 \u0448\u0440\u0438\u0444\u0442", + "\u05d2\u05d5\u05e4\u05df \u05de\u05e2\u05e8\u05db\u05ea", + "\u0627\u0644\u0628\u064a\u0627\u0646", + "\u0627\u0644\u062a\u0627\u0631\u064a\u062e", + "\u0627\u0644\u0646\u064a\u0644", + "\u0628\u063a\u062f\u0627\u062f", + "\u0628\u064a\u0631\u0648\u062a", + "\u062c\u064a\u0632\u0629", + "\u062e\u0637 \u0627\u0644\u0646\u0638\u0627\u0645", + "\u062f\u0645\u0634\u0642", + "\u062f\u064a\u0648\u0627\u0646 \u062b\u0644\u062b", + "\u062f\u064a\u0648\u0627\u0646 \u0643\u0648\u0641\u064a", + "\u0635\u0646\u0639\u0627\u0621", + "\u0641\u0627\u0631\u0633\u064a", + "\u0641\u0631\u062d", + "\u0643\u0648\u0641\u064a", + "\u0645\u0646\u0649", + "\u0645\u0650\u0635\u062d\u0641\u064a", + "\u0645\u0650\u0635\u062d\u0641\u064a \u0630\u0647\u0628\u064a", + "\u0646\u062f\u064a\u0645", + "\u0646\u0633\u062e", + "\u0648\u0633\u064a\u0645", + "\u0906\u0908\u0970\u091f\u0940\u0970\u090f\u092b\u093c\u0970 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940", + "\u0906\u0908\u0970\u091f\u0940\u0970\u090f\u092b\u093c\u0970 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u092e\u0930\u093e\u0920\u0940", + "\u0915\u094b\u0939\u093f\u0928\u0942\u0930 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940", + "\u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u090f\u092e\u0970\u091f\u0940\u0970", + "\u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u0938\u0902\u0917\u092e \u090f\u092e\u0970\u090f\u0928\u0970", + "\u0936\u094d\u0930\u0940 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u096d\u0967\u096a", + "\u0e41\u0e1a\u0e1a\u0e2d\u0e31\u0e01\u0e29\u0e23\u0e23\u0e30\u0e1a\u0e1a", + "\u2e41\u7175\u6120\u82a9\u82c8", + "\u30b7\u30b9\u30c6\u30e0\u30d5\u30a9\u30f3\u30c8", + "\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 Pro", + "\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 Pro W4", + "\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 ProN", + "\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 ProN W4", + "\u30d2\u30e9\u30ae\u30ce\u660e\u671d Pro", + "\u30d2\u30e9\u30ae\u30ce\u660e\u671d Pro W3", + "\u30d2\u30e9\u30ae\u30ce\u660e\u671d Pro W6", + "\u30d2\u30e9\u30ae\u30ce\u660e\u671d ProN", + "\u30d2\u30e9\u30ae\u30ce\u660e\u671d ProN W3", + "\u30d2\u30e9\u30ae\u30ce\u660e\u671d ProN W6", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Pro", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Pro W3", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Pro W6", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 ProN", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 ProN W3", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 ProN W6", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Std", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Std W8", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 StdN", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 StdN W8", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 \u7c21\u4f53\u4e2d\u6587", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 \u7c21\u4f53\u4e2d\u6587 W3", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 \u7c21\u4f53\u4e2d\u6587 W6", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W0", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W1", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W2", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W3", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W4", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W5", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W6", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W7", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W8", + "\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W9", + "\u51ac\u9752\u9ed1\u4f53\u7b80\u4f53\u4e2d\u6587", + "\u51ac\u9752\u9ed1\u4f53\u7b80\u4f53\u4e2d\u6587 W3", + "\u51ac\u9752\u9ed1\u4f53\u7b80\u4f53\u4e2d\u6587 W6", + "\u51ac\u9752\u9ed1\u9ad4\u7c21\u9ad4\u4e2d\u6587", + "\u51ac\u9752\u9ed1\u9ad4\u7c21\u9ad4\u4e2d\u6587 W3", + "\u51ac\u9752\u9ed1\u9ad4\u7c21\u9ad4\u4e2d\u6587 W6", + "\u5b8b\u4f53-\u7b80", + "\u5b8b\u4f53-\u7e41", + "\u5b8b\u9ad4-\u7c21", + "\u5b8b\u9ad4-\u7e41", + "\u7cfb\u7d71\u5b57\u9ad4", + "\u7cfb\u7edf\u5b57\u4f53", + "\u82f9\u65b9-\u6e2f", + "\u82f9\u65b9-\u7b80", + "\u82f9\u65b9-\u7e41", + "\u8371\u8389\u834d\u836d\u8a70\u8353\u2050\u726f", + "\u8371\u8389\u834d\u836d\u8a70\u8353\u2053\u7464", + "\u8371\u8389\u834d\u836d\u8a70\u8353\u8356\u8362\u834e", + "\u8371\u8389\u834d\u836d\u8adb\u8353\u2050\u726f", + "\u8371\u8389\u834d\u836d\u96be\u92a9\u2050\u726f", + "\u860b\u65b9-\u6e2f", + "\u860b\u65b9-\u7c21", + "\u860b\u65b9-\u7e41", + "\u9ed1\u4f53-\u7b80", + "\u9ed1\u4f53-\u7e41", + "\u9ed1\u9ad4-\u7c21", + "\u9ed1\u9ad4-\u7e41", + "\u9ed2\u4f53-\u7c21", + "\u9ed2\u4f53-\u7e41", + "\uc2dc\uc2a4\ud15c \uc11c\uccb4" + ], + "lin": [ + "Arimo", + "Cousine", + "Noto Naskh Arabic", + "Noto Sans Adlam", + "Noto Sans Armenian", + "Noto Sans Balinese", + "Noto Sans Bamum", + "Noto Sans Bassa Vah", + "Noto Sans Batak", + "Noto Sans Bengali", + "Noto Sans Buginese", + "Noto Sans Buhid", + "Noto Sans Canadian Aboriginal", + "Noto Sans Chakma", + "Noto Sans Cham", + "Noto Sans Cherokee", + "Noto Sans Coptic", + "Noto Sans Deseret", + "Noto Sans Devanagari", + "Noto Sans Elbasan", + "Noto Sans Ethiopic", + "Noto Sans Georgian", + "Noto Sans Grantha", + "Noto Sans Gujarati", + "Noto Sans Gunjala Gondi", + "Noto Sans Gurmukhi", + "Noto Sans Hanifi Rohingya", + "Noto Sans Hanunoo", + "Noto Sans Hebrew", + "Noto Sans JP", + "Noto Sans Javanese", + "Noto Sans KR", + "Noto Sans Kannada", + "Noto Sans Kayah Li", + "Noto Sans Khmer", + "Noto Sans Khojki", + "Noto Sans Khudawadi", + "Noto Sans Lao", + "Noto Sans Lepcha", + "Noto Sans Limbu", + "Noto Sans Lisu", + "Noto Sans Mahajani", + "Noto Sans Malayalam", + "Noto Sans Mandaic", + "Noto Sans Masaram Gondi", + "Noto Sans Medefaidrin", + "Noto Sans Meetei Mayek", + "Noto Sans Mende Kikakui", + "Noto Sans Miao", + "Noto Sans Modi", + "Noto Sans Mongolian", + "Noto Sans Mro", + "Noto Sans Multani", + "Noto Sans Myanmar", + "Noto Sans NKo", + "Noto Sans New Tai Lue", + "Noto Sans Newa", + "Noto Sans Ol Chiki", + "Noto Sans Oriya", + "Noto Sans Osage", + "Noto Sans Osmanya", + "Noto Sans Pahawh Hmong", + "Noto Sans Pau Cin Hau", + "Noto Sans Rejang", + "Noto Sans Runic", + "Noto Sans SC", + "Noto Sans Samaritan", + "Noto Sans Saurashtra", + "Noto Sans Sharada", + "Noto Sans Shavian", + "Noto Sans Sinhala", + "Noto Sans Sora Sompeng", + "Noto Sans Soyombo", + "Noto Sans Sundanese", + "Noto Sans Syloti Nagri", + "Noto Sans Symbols", + "Noto Sans Symbols 2", + "Noto Sans Syriac", + "Noto Sans TC", + "Noto Sans Tagalog", + "Noto Sans Tagbanwa", + "Noto Sans Tai Le", + "Noto Sans Tai Tham", + "Noto Sans Tai Viet", + "Noto Sans Takri", + "Noto Sans Tamil", + "Noto Sans Telugu", + "Noto Sans Thaana", + "Noto Sans Thai", + "Noto Sans Tifinagh", + "Noto Sans Tifinagh APT", + "Noto Sans Tifinagh Adrar", + "Noto Sans Tifinagh Agraw Imazighen", + "Noto Sans Tifinagh Ahaggar", + "Noto Sans Tifinagh Air", + "Noto Sans Tifinagh Azawagh", + "Noto Sans Tifinagh Ghat", + "Noto Sans Tifinagh Hawad", + "Noto Sans Tifinagh Rhissa Ixa", + "Noto Sans Tifinagh SIL", + "Noto Sans Tifinagh Tawellemmet", + "Noto Sans Tirhuta", + "Noto Sans Vai", + "Noto Sans Wancho", + "Noto Sans Warang Citi", + "Noto Sans Yi", + "Noto Sans Zanabazar Square", + "Noto Serif Armenian", + "Noto Serif Balinese", + "Noto Serif Bengali", + "Noto Serif Devanagari", + "Noto Serif Dogra", + "Noto Serif Ethiopic", + "Noto Serif Georgian", + "Noto Serif Grantha", + "Noto Serif Gujarati", + "Noto Serif Gurmukhi", + "Noto Serif Hebrew", + "Noto Serif Kannada", + "Noto Serif Khmer", + "Noto Serif Khojki", + "Noto Serif Lao", + "Noto Serif Malayalam", + "Noto Serif Myanmar", + "Noto Serif NP Hmong", + "Noto Serif Sinhala", + "Noto Serif Tamil", + "Noto Serif Telugu", + "Noto Serif Thai", + "Noto Serif Tibetan", + "Noto Serif Yezidi", + "STIX Two Math", + "Tinos", + "Twemoji Mozilla" + ] +} diff --git a/src-tauri/src/camoufox/data/header-network-definition.zip b/src-tauri/src/camoufox/data/header-network-definition.zip new file mode 100644 index 0000000..423228a Binary files /dev/null and b/src-tauri/src/camoufox/data/header-network-definition.zip differ diff --git a/src-tauri/src/camoufox/data/headers-order.json b/src-tauri/src/camoufox/data/headers-order.json new file mode 100644 index 0000000..a25f2a1 --- /dev/null +++ b/src-tauri/src/camoufox/data/headers-order.json @@ -0,0 +1,164 @@ +{ + "safari": [ + "Referer", + "Origin", + "Content-Type", + "Accept", + "Upgrade-Insecure-Requests", + "User-Agent", + "Content-Length", + "Accept-Encoding", + "Accept-Language", + "Connection", + "Host", + "Cookie", + "Sec-Fetch-Dest", + "Sec-Fetch-Mode", + "Sec-Fetch-Site", + ":method", + ":scheme", + ":authority", + ":path", + "referer", + "origin", + "content-type", + "accept", + "user-agent", + "content-length", + "accept-encoding", + "accept-language", + "cookie", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site" + ], + "chrome": [ + "Host", + "Connection", + "Content-Length", + "Cache-Control", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "Origin", + "Content-Type", + "Upgrade-Insecure-Requests", + "User-Agent", + "Accept", + "Sec-Fetch-Site", + "Sec-Fetch-Mode", + "Sec-Fetch-User", + "Sec-Fetch-Dest", + "Referer", + "Accept-Encoding", + "Accept-Language", + "Cookie", + ":method", + ":authority", + ":scheme", + ":path", + "content-length", + "cache-control", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "origin", + "content-type", + "upgrade-insecure-requests", + "user-agent", + "accept", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-user", + "sec-fetch-dest", + "referer", + "accept-encoding", + "accept-language", + "cookie", + "priority" + ], + "firefox": [ + "Host", + "User-Agent", + "Accept", + "Accept-Language", + "Accept-Encoding", + "Content-Type", + "Content-Length", + "Origin", + "Connection", + "Referer", + "Cookie", + "Upgrade-Insecure-Requests", + "Sec-Fetch-Dest", + "Sec-Fetch-Mode", + "Sec-Fetch-Site", + "Sec-Fetch-User", + "Priority", + ":method", + ":path", + ":authority", + ":scheme", + "user-agent", + "accept", + "accept-language", + "accept-encoding", + "content-type", + "content-length", + "origin", + "referer", + "cookie", + "upgrade-insecure-requests", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "sec-fetch-user", + "priority", + "te" + ], + "edge": [ + "Host", + "Connection", + "Content-Length", + "Cache-Control", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "Origin", + "Content-Type", + "Upgrade-Insecure-Requests", + "User-Agent", + "Accept", + "Sec-Fetch-Site", + "Sec-Fetch-Mode", + "Sec-Fetch-User", + "Sec-Fetch-Dest", + "Referer", + "Accept-Encoding", + "Accept-Language", + "Cookie", + ":method", + ":authority", + ":scheme", + ":path", + "content-length", + "cache-control", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "origin", + "content-type", + "upgrade-insecure-requests", + "user-agent", + "accept", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-user", + "sec-fetch-dest", + "referer", + "accept-encoding", + "accept-language", + "cookie", + "priority" + ] +} diff --git a/src-tauri/src/camoufox/data/input-network-definition.zip b/src-tauri/src/camoufox/data/input-network-definition.zip new file mode 100644 index 0000000..3d45833 Binary files /dev/null and b/src-tauri/src/camoufox/data/input-network-definition.zip differ diff --git a/src-tauri/src/camoufox/data/mod.rs b/src-tauri/src/camoufox/data/mod.rs new file mode 100644 index 0000000..f2d20fd --- /dev/null +++ b/src-tauri/src/camoufox/data/mod.rs @@ -0,0 +1,9 @@ +pub const FINGERPRINT_NETWORK_ZIP: &[u8] = include_bytes!("fingerprint-network-definition.zip"); +pub const INPUT_NETWORK_ZIP: &[u8] = include_bytes!("input-network-definition.zip"); +pub const HEADER_NETWORK_ZIP: &[u8] = include_bytes!("header-network-definition.zip"); +pub const BROWSER_HELPER_JSON: &str = include_str!("browser-helper-file.json"); +pub const HEADERS_ORDER_JSON: &str = include_str!("headers-order.json"); +pub const FONTS_JSON: &str = include_str!("fonts.json"); +pub const BROWSERFORGE_YML: &str = include_str!("browserforge.yml"); +pub const WEBGL_DATA_DB: &[u8] = include_bytes!("webgl_data.db"); +pub const TERRITORY_INFO_XML: &str = include_str!("territoryInfo.xml"); diff --git a/src-tauri/src/camoufox/data/territoryInfo.xml b/src-tauri/src/camoufox/data/territoryInfo.xml new file mode 100644 index 0000000..3526fca --- /dev/null +++ b/src-tauri/src/camoufox/data/territoryInfo.xml @@ -0,0 +1,2024 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/src/camoufox/data/webgl_data.db b/src-tauri/src/camoufox/data/webgl_data.db new file mode 100644 index 0000000..f8448aa Binary files /dev/null and b/src-tauri/src/camoufox/data/webgl_data.db differ diff --git a/src-tauri/src/camoufox/env_vars.rs b/src-tauri/src/camoufox/env_vars.rs new file mode 100644 index 0000000..09b3489 --- /dev/null +++ b/src-tauri/src/camoufox/env_vars.rs @@ -0,0 +1,142 @@ +//! Environment variable handling for Camoufox configuration. +//! +//! Camoufox reads its configuration from environment variables named CAMOU_CONFIG_1, CAMOU_CONFIG_2, etc. +//! The configuration JSON is chunked to fit within environment variable size limits. + +use std::collections::HashMap; + +/// Maximum chunk size for environment variables on Windows. +const CHUNK_SIZE_WINDOWS: usize = 2047; + +/// Maximum chunk size for environment variables on Unix systems. +const CHUNK_SIZE_UNIX: usize = 32767; + +/// Get the chunk size for the current platform. +fn get_chunk_size() -> usize { + if cfg!(windows) { + CHUNK_SIZE_WINDOWS + } else { + CHUNK_SIZE_UNIX + } +} + +/// Convert a Camoufox config map to environment variables. +/// +/// The config is serialized to JSON and split into chunks that fit within +/// environment variable size limits. Each chunk is stored in a variable +/// named CAMOU_CONFIG_1, CAMOU_CONFIG_2, etc. +pub fn config_to_env_vars( + config: &HashMap, +) -> Result, serde_json::Error> { + let config_json = serde_json::to_string(config)?; + Ok(chunk_config_string(&config_json)) +} + +/// Split a config string into chunks and create environment variable map. +pub fn chunk_config_string(config_str: &str) -> HashMap { + let chunk_size = get_chunk_size(); + let mut env_vars = HashMap::new(); + + for (i, chunk) in config_str.as_bytes().chunks(chunk_size).enumerate() { + let chunk_str = String::from_utf8_lossy(chunk).to_string(); + let env_name = format!("CAMOU_CONFIG_{}", i + 1); + env_vars.insert(env_name, chunk_str); + } + + env_vars +} + +/// Determine the target OS from a user agent string. +pub fn determine_ua_os(user_agent: &str) -> &'static str { + let ua_lower = user_agent.to_lowercase(); + + if ua_lower.contains("mac os") || ua_lower.contains("macos") || ua_lower.contains("macintosh") { + "mac" + } else if ua_lower.contains("windows") { + "win" + } else { + "lin" + } +} + +/// Get the fontconfig path environment variable for Linux. +pub fn get_fontconfig_env(target_os: &str, camoufox_path: &std::path::Path) -> Option { + if cfg!(target_os = "linux") { + let fontconfig_dir = camoufox_path.join("fontconfig").join(target_os); + if fontconfig_dir.exists() { + return Some(fontconfig_dir.to_string_lossy().to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chunk_small_config() { + let config = r#"{"navigator.userAgent": "Mozilla/5.0"}"#; + let env_vars = chunk_config_string(config); + + assert_eq!(env_vars.len(), 1); + assert!(env_vars.contains_key("CAMOU_CONFIG_1")); + assert_eq!(env_vars.get("CAMOU_CONFIG_1").unwrap(), config); + } + + #[test] + fn test_chunk_large_config() { + // Create a config string larger than the chunk size + let chunk_size = get_chunk_size(); + let large_value = "x".repeat(chunk_size * 2 + 100); + let config = format!(r#"{{"key": "{}"}}"#, large_value); + + let env_vars = chunk_config_string(&config); + + // Should have at least 2 chunks + assert!(env_vars.len() >= 2); + assert!(env_vars.contains_key("CAMOU_CONFIG_1")); + assert!(env_vars.contains_key("CAMOU_CONFIG_2")); + + // Reconstruct and verify + let mut reconstructed = String::new(); + let mut i = 1; + while let Some(chunk) = env_vars.get(&format!("CAMOU_CONFIG_{}", i)) { + reconstructed.push_str(chunk); + i += 1; + } + assert_eq!(reconstructed, config); + } + + #[test] + fn test_determine_ua_os_windows() { + let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0"; + assert_eq!(determine_ua_os(ua), "win"); + } + + #[test] + fn test_determine_ua_os_macos() { + let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0"; + assert_eq!(determine_ua_os(ua), "mac"); + } + + #[test] + fn test_determine_ua_os_linux() { + let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"; + assert_eq!(determine_ua_os(ua), "lin"); + } + + #[test] + fn test_config_to_env_vars() { + let mut config = HashMap::new(); + config.insert( + "navigator.userAgent".to_string(), + serde_json::json!("Mozilla/5.0 Firefox/135.0"), + ); + config.insert("screen.width".to_string(), serde_json::json!(1920)); + + let env_vars = config_to_env_vars(&config).unwrap(); + assert!(!env_vars.is_empty()); + assert!(env_vars.contains_key("CAMOU_CONFIG_1")); + } +} diff --git a/src-tauri/src/camoufox/fingerprint/bayesian_network.rs b/src-tauri/src/camoufox/fingerprint/bayesian_network.rs new file mode 100644 index 0000000..75cce0c --- /dev/null +++ b/src-tauri/src/camoufox/fingerprint/bayesian_network.rs @@ -0,0 +1,198 @@ +//! Bayesian network for fingerprint generation. +//! +//! Loads pre-trained probability distributions from ZIP files and samples fingerprints. + +use super::bayesian_node::{BayesianNode, NodeDefinition}; +use serde::Deserialize; +use std::collections::HashMap; +use std::io::{Cursor, Read}; +use zip::ZipArchive; + +/// Network definition structure from the ZIP file. +#[derive(Debug, Deserialize)] +pub struct NetworkDefinition { + pub nodes: Vec, +} + +/// A Bayesian network for generating consistent fingerprints. +pub struct BayesianNetwork { + nodes_in_sampling_order: Vec, + nodes_by_name: HashMap, +} + +impl BayesianNetwork { + /// Load a Bayesian network from embedded ZIP file bytes. + pub fn from_zip_bytes(zip_bytes: &[u8]) -> Result { + let cursor = Cursor::new(zip_bytes); + let mut archive = ZipArchive::new(cursor)?; + + // Find and read the JSON file from the ZIP + let mut json_content = String::new(); + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + if file.name().ends_with(".json") { + file.read_to_string(&mut json_content)?; + break; + } + } + + if json_content.is_empty() { + return Err(BayesianNetworkError::NoJsonInZip); + } + + let definition: NetworkDefinition = serde_json::from_str(&json_content)?; + + let mut nodes_in_sampling_order = Vec::with_capacity(definition.nodes.len()); + let mut nodes_by_name = HashMap::with_capacity(definition.nodes.len()); + + for (i, node_def) in definition.nodes.into_iter().enumerate() { + nodes_by_name.insert(node_def.name.clone(), i); + nodes_in_sampling_order.push(BayesianNode::new(node_def)); + } + + Ok(Self { + nodes_in_sampling_order, + nodes_by_name, + }) + } + + /// Get a node by name. + pub fn get_node(&self, name: &str) -> Option<&BayesianNode> { + self + .nodes_by_name + .get(name) + .map(|&i| &self.nodes_in_sampling_order[i]) + } + + /// Get possible values for a node. + pub fn get_possible_values(&self, name: &str) -> Option> { + self + .get_node(name) + .map(|node| node.possible_values().to_vec()) + } + + /// Generate a random sample from the network. + /// + /// `input_values` contains already known node values that should not be overwritten. + pub fn generate_sample(&self, input_values: &HashMap) -> HashMap { + let mut sample = input_values.clone(); + + for node in &self.nodes_in_sampling_order { + if !sample.contains_key(node.name()) { + let value = node.sample(&sample); + sample.insert(node.name().to_string(), value); + } + } + + sample + } + + /// Generate a random sample consistent with the given value restrictions. + /// + /// Uses backtracking to find a valid configuration. + /// Returns `None` if no consistent sample can be generated. + pub fn generate_consistent_sample_when_possible( + &self, + value_possibilities: &HashMap>, + ) -> Option> { + self.recursively_generate_consistent_sample(HashMap::new(), value_possibilities, 0) + } + + fn recursively_generate_consistent_sample( + &self, + sample_so_far: HashMap, + value_possibilities: &HashMap>, + depth: usize, + ) -> Option> { + if depth >= self.nodes_in_sampling_order.len() { + return Some(sample_so_far); + } + + let node = &self.nodes_in_sampling_order[depth]; + let mut banned_values: Vec = Vec::new(); + let mut sample_so_far = sample_so_far; + + loop { + let sample_value = node.sample_according_to_restrictions( + &sample_so_far, + value_possibilities.get(node.name()).map(|v| v.as_slice()), + &banned_values, + ); + + let Some(value) = sample_value else { + break; + }; + + sample_so_far.insert(node.name().to_string(), value.clone()); + + if let Some(complete_sample) = self.recursively_generate_consistent_sample( + sample_so_far.clone(), + value_possibilities, + depth + 1, + ) { + return Some(complete_sample); + } + + banned_values.push(value); + } + + None + } +} + +/// Errors that can occur when working with Bayesian networks. +#[derive(Debug, thiserror::Error)] +pub enum BayesianNetworkError { + #[error("ZIP file error: {0}")] + Zip(#[from] zip::result::ZipError), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON parsing error: {0}")] + Json(#[from] serde_json::Error), + + #[error("No JSON file found in ZIP archive")] + NoJsonInZip, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_input_network() { + let zip_bytes = include_bytes!("../data/input-network-definition.zip"); + let network = BayesianNetwork::from_zip_bytes(zip_bytes); + assert!( + network.is_ok(), + "Failed to load input network: {:?}", + network.err() + ); + } + + #[test] + fn test_generate_sample_from_input_network() { + let zip_bytes = include_bytes!("../data/input-network-definition.zip"); + let network = BayesianNetwork::from_zip_bytes(zip_bytes).unwrap(); + + let sample = network.generate_sample(&HashMap::new()); + assert!(!sample.is_empty(), "Sample should not be empty"); + } + + #[test] + fn test_generate_consistent_sample() { + let zip_bytes = include_bytes!("../data/input-network-definition.zip"); + let network = BayesianNetwork::from_zip_bytes(zip_bytes).unwrap(); + + let mut constraints = HashMap::new(); + constraints.insert("*OPERATING_SYSTEM".to_string(), vec!["windows".to_string()]); + + let sample = network.generate_consistent_sample_when_possible(&constraints); + assert!(sample.is_some(), "Should generate a consistent sample"); + + if let Some(s) = sample { + assert_eq!(s.get("*OPERATING_SYSTEM"), Some(&"windows".to_string())); + } + } +} diff --git a/src-tauri/src/camoufox/fingerprint/bayesian_node.rs b/src-tauri/src/camoufox/fingerprint/bayesian_node.rs new file mode 100644 index 0000000..d7893ef --- /dev/null +++ b/src-tauri/src/camoufox/fingerprint/bayesian_node.rs @@ -0,0 +1,231 @@ +//! Bayesian network node implementation for fingerprint generation. +//! +//! Implements weighted random sampling from conditional probability distributions. + +use rand::Rng; +use serde::Deserialize; +use std::collections::HashMap; + +/// Node definition from the network JSON file. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeDefinition { + pub name: String, + pub parent_names: Vec, + pub possible_values: Vec, + pub conditional_probabilities: ConditionalProbabilities, +} + +/// Conditional probability structure - can be nested or terminal. +#[derive(Debug, Clone, Deserialize)] +pub struct ConditionalProbabilities { + #[serde(default)] + pub deeper: Option>, + #[serde(default)] + pub skip: Option>, + #[serde(flatten)] + pub probabilities: HashMap, +} + +impl ConditionalProbabilities { + /// Check if this is a terminal node (has actual probabilities, not deeper nesting) + pub fn is_terminal(&self) -> bool { + self.deeper.is_none() + } +} + +/// A single node in the Bayesian network. +pub struct BayesianNode { + definition: NodeDefinition, +} + +impl BayesianNode { + pub fn new(definition: NodeDefinition) -> Self { + Self { definition } + } + + pub fn name(&self) -> &str { + &self.definition.name + } + + pub fn parent_names(&self) -> &[String] { + &self.definition.parent_names + } + + pub fn possible_values(&self) -> &[String] { + &self.definition.possible_values + } + + /// Get the probability distribution given parent node values. + fn get_probabilities_given_known_values( + &self, + parent_values: &HashMap, + ) -> HashMap { + let mut probabilities = &self.definition.conditional_probabilities; + + for parent_name in &self.definition.parent_names { + if let Some(deeper) = &probabilities.deeper { + if let Some(parent_value) = parent_values.get(parent_name) { + if let Some(next_level) = deeper.get(parent_value) { + probabilities = next_level; + continue; + } + } + // Use skip if parent value not found in deeper + if let Some(skip) = &probabilities.skip { + probabilities = skip; + } + } + } + + probabilities.probabilities.clone() + } + + /// Randomly sample from the given values using the given probabilities. + fn sample_random_value_from_possibilities( + possible_values: &[String], + total_probability: f64, + probabilities: &HashMap, + ) -> String { + if possible_values.is_empty() { + return String::new(); + } + + let mut rng = rand::rng(); + let anchor = rng.random::() * total_probability; + let mut cumulative = 0.0; + + for value in possible_values { + if let Some(&prob) = probabilities.get(value) { + cumulative += prob; + if cumulative > anchor { + return value.clone(); + } + } + } + + possible_values.first().cloned().unwrap_or_default() + } + + /// Sample a value from the conditional distribution given parent values. + pub fn sample(&self, parent_values: &HashMap) -> String { + let probabilities = self.get_probabilities_given_known_values(parent_values); + let possible_values: Vec = probabilities.keys().cloned().collect(); + + Self::sample_random_value_from_possibilities(&possible_values, 1.0, &probabilities) + } + + /// Sample according to restrictions on possible values. + /// + /// Returns `None` if no valid value can be sampled. + pub fn sample_according_to_restrictions( + &self, + parent_values: &HashMap, + value_possibilities: Option<&[String]>, + banned_values: &[String], + ) -> Option { + let probabilities = self.get_probabilities_given_known_values(parent_values); + let values_in_distribution: Vec = probabilities.keys().cloned().collect(); + + let possible_values = value_possibilities.unwrap_or(&values_in_distribution); + + let mut valid_values = Vec::new(); + let mut total_probability = 0.0; + + for value in possible_values { + if !banned_values.contains(value) && values_in_distribution.contains(value) { + if let Some(&prob) = probabilities.get(value) { + valid_values.push(value.clone()); + total_probability += prob; + } + } + } + + if valid_values.is_empty() { + return None; + } + + Some(Self::sample_random_value_from_possibilities( + &valid_values, + total_probability, + &probabilities, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_node() -> BayesianNode { + let mut probs = HashMap::new(); + probs.insert("1920".to_string(), 0.5); + probs.insert("1366".to_string(), 0.3); + probs.insert("1536".to_string(), 0.2); + + let definition = NodeDefinition { + name: "screen.width".to_string(), + parent_names: vec![], + possible_values: vec!["1920".to_string(), "1366".to_string(), "1536".to_string()], + conditional_probabilities: ConditionalProbabilities { + deeper: None, + skip: None, + probabilities: probs, + }, + }; + + BayesianNode::new(definition) + } + + #[test] + fn test_sample_returns_valid_value() { + let node = create_test_node(); + let parent_values = HashMap::new(); + + for _ in 0..100 { + let value = node.sample(&parent_values); + assert!( + node.possible_values().contains(&value), + "Sampled value '{}' not in possible values", + value + ); + } + } + + #[test] + fn test_sample_with_restrictions() { + let node = create_test_node(); + let parent_values = HashMap::new(); + + let allowed = vec!["1920".to_string()]; + let banned = vec![]; + + let value = node.sample_according_to_restrictions(&parent_values, Some(&allowed), &banned); + + assert_eq!(value, Some("1920".to_string())); + } + + #[test] + fn test_sample_with_banned_values() { + let node = create_test_node(); + let parent_values = HashMap::new(); + + let banned = vec!["1920".to_string(), "1366".to_string()]; + + for _ in 0..100 { + let value = node.sample_according_to_restrictions(&parent_values, None, &banned); + assert_eq!(value, Some("1536".to_string())); + } + } + + #[test] + fn test_sample_returns_none_when_all_banned() { + let node = create_test_node(); + let parent_values = HashMap::new(); + + let banned = vec!["1920".to_string(), "1366".to_string(), "1536".to_string()]; + + let value = node.sample_according_to_restrictions(&parent_values, None, &banned); + assert!(value.is_none()); + } +} diff --git a/src-tauri/src/camoufox/fingerprint/mod.rs b/src-tauri/src/camoufox/fingerprint/mod.rs new file mode 100644 index 0000000..52c8e5f --- /dev/null +++ b/src-tauri/src/camoufox/fingerprint/mod.rs @@ -0,0 +1,569 @@ +//! Fingerprint generation module. +//! +//! Generates realistic browser fingerprints using Bayesian networks trained on real browser data. + +pub mod bayesian_network; +pub mod bayesian_node; +pub mod types; + +use bayesian_network::{BayesianNetwork, BayesianNetworkError}; +use std::collections::HashMap; +use types::*; + +use crate::camoufox::data; + +/// Fingerprint generator using Bayesian networks. +pub struct FingerprintGenerator { + fingerprint_network: BayesianNetwork, + input_network: BayesianNetwork, + header_network: BayesianNetwork, + browser_helper: Vec, + headers_order: HashMap>, +} + +/// Parsed browser/HTTP version info. +#[derive(Debug, Clone)] +pub struct BrowserHttpInfo { + pub name: String, + pub version: Vec, + pub http_version: String, + pub complete_string: String, +} + +impl BrowserHttpInfo { + fn parse(s: &str) -> Option { + if s == MISSING_VALUE_DATASET_TOKEN { + return None; + } + + let parts: Vec<&str> = s.split('|').collect(); + if parts.len() != 2 { + return None; + } + + let browser_string = parts[0]; + let http_version = parts[1].to_string(); + + let browser_parts: Vec<&str> = browser_string.split('/').collect(); + if browser_parts.len() != 2 { + return None; + } + + let name = browser_parts[0].to_string(); + let version: Vec = browser_parts[1] + .split('.') + .filter_map(|v| v.parse().ok()) + .collect(); + + Some(Self { + name, + version, + http_version, + complete_string: s.to_string(), + }) + } + + pub fn major_version(&self) -> u32 { + self.version.first().copied().unwrap_or(0) + } +} + +/// Error type for fingerprint generation. +#[derive(Debug, thiserror::Error)] +pub enum FingerprintError { + #[error("Bayesian network error: {0}")] + Network(#[from] BayesianNetworkError), + + #[error("JSON parsing error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Failed to generate consistent fingerprint after {0} attempts")] + GenerationFailed(u32), + + #[error("No valid fingerprint generated")] + NoValidFingerprint, +} + +impl FingerprintGenerator { + /// Create a new fingerprint generator. + pub fn new() -> Result { + let fingerprint_network = BayesianNetwork::from_zip_bytes(data::FINGERPRINT_NETWORK_ZIP)?; + let input_network = BayesianNetwork::from_zip_bytes(data::INPUT_NETWORK_ZIP)?; + let header_network = BayesianNetwork::from_zip_bytes(data::HEADER_NETWORK_ZIP)?; + + let browser_strings: Vec = serde_json::from_str(data::BROWSER_HELPER_JSON)?; + let browser_helper: Vec = browser_strings + .iter() + .filter_map(|s| BrowserHttpInfo::parse(s)) + .collect(); + + let headers_order: HashMap> = + serde_json::from_str(data::HEADERS_ORDER_JSON)?; + + Ok(Self { + fingerprint_network, + input_network, + header_network, + browser_helper, + headers_order, + }) + } + + /// Generate a fingerprint with matching headers. + pub fn get_fingerprint( + &self, + options: &FingerprintOptions, + ) -> Result { + const MAX_RETRIES: u32 = 10; + + // Build constraints from options + let mut value_possibilities = self.build_constraints(options); + + // Handle screen constraints + let screen_values = if let Some(screen_constraints) = &options.screen { + self.filter_screen_values(screen_constraints) + } else { + None + }; + + if let Some(sv) = screen_values { + value_possibilities.insert("screen".to_string(), sv); + } + + for attempt in 0..MAX_RETRIES { + // Generate input sample consistent with constraints + let input_sample = self + .input_network + .generate_consistent_sample_when_possible(&value_possibilities); + + let Some(input_sample) = input_sample else { + continue; + }; + + // Generate header sample from input + let header_sample = self.header_network.generate_sample(&input_sample); + + // Extract user agent + let user_agent = header_sample + .get("user-agent") + .or_else(|| header_sample.get("User-Agent")) + .cloned() + .unwrap_or_default(); + + // Build fingerprint constraints with the generated user agent + let mut fp_constraints = value_possibilities.clone(); + fp_constraints.insert("userAgent".to_string(), vec![user_agent.clone()]); + + // Generate fingerprint sample + let fingerprint_sample = self + .fingerprint_network + .generate_consistent_sample_when_possible(&fp_constraints); + + let Some(fp_sample) = fingerprint_sample else { + log::debug!( + "Failed to generate fingerprint on attempt {}, retrying", + attempt + 1 + ); + continue; + }; + + // Transform the sample to a Fingerprint struct + match self.transform_sample(&fp_sample, &header_sample, options) { + Ok(result) => return Ok(result), + Err(e) => { + log::debug!( + "Failed to transform fingerprint on attempt {}: {}", + attempt + 1, + e + ); + continue; + } + } + } + + Err(FingerprintError::GenerationFailed(MAX_RETRIES)) + } + + /// Build constraint map from options. + fn build_constraints(&self, options: &FingerprintOptions) -> HashMap> { + let mut constraints = HashMap::new(); + + // Operating system constraint + if let Some(os) = &options.operating_system { + constraints.insert(OPERATING_SYSTEM_NODE_NAME.to_string(), vec![os.clone()]); + } + + // Device constraint (default to desktop) + let devices = options + .devices + .clone() + .unwrap_or_else(|| vec!["desktop".to_string()]); + constraints.insert(DEVICE_NODE_NAME.to_string(), devices); + + // Browser constraint + let browsers = options + .browsers + .clone() + .unwrap_or_else(|| SUPPORTED_BROWSERS.iter().map(|s| s.to_string()).collect()); + + let http_version = options + .http_version + .clone() + .unwrap_or_else(|| "2".to_string()); + + // Filter browser helper entries by browser names and HTTP version + let browser_http_values: Vec = self + .browser_helper + .iter() + .filter(|bh| browsers.contains(&bh.name) && bh.http_version == http_version) + .map(|bh| bh.complete_string.clone()) + .collect(); + + if !browser_http_values.is_empty() { + constraints.insert(BROWSER_HTTP_NODE_NAME.to_string(), browser_http_values); + } + + constraints + } + + /// Filter screen values based on constraints. + fn filter_screen_values(&self, constraints: &ScreenConstraints) -> Option> { + let possible_values = self.fingerprint_network.get_possible_values("screen")?; + + let filtered: Vec = possible_values + .into_iter() + .filter(|screen_str| { + // Screen values are stored as "*STRINGIFIED*{...json...}" + if let Some(json_str) = screen_str.strip_prefix(STRINGIFIED_PREFIX) { + if let Ok(screen) = serde_json::from_str::(json_str) { + let width = screen["width"].as_u64().unwrap_or(0) as u32; + let height = screen["height"].as_u64().unwrap_or(0) as u32; + return constraints.matches(width, height); + } + } + true + }) + .collect(); + + if filtered.is_empty() { + None + } else { + Some(filtered) + } + } + + /// Transform raw sample data into a Fingerprint struct. + fn transform_sample( + &self, + fp_sample: &HashMap, + header_sample: &HashMap, + options: &FingerprintOptions, + ) -> Result { + // Parse values, handling STRINGIFIED prefix and MISSING_VALUE token + let mut parsed: HashMap = HashMap::new(); + + for (key, value) in fp_sample { + if value == MISSING_VALUE_DATASET_TOKEN { + continue; + } + + let parsed_value = if let Some(json_str) = value.strip_prefix(STRINGIFIED_PREFIX) { + serde_json::from_str(json_str)? + } else { + serde_json::Value::String(value.clone()) + }; + + parsed.insert(key.clone(), parsed_value); + } + + // Check if screen was generated + let screen_value = parsed.get("screen"); + if screen_value.is_none() { + return Err(FingerprintError::NoValidFingerprint); + } + + // Extract screen fingerprint + let screen = if let Some(screen_val) = screen_value { + serde_json::from_value(screen_val.clone()).unwrap_or_default() + } else { + ScreenFingerprint::default() + }; + + // Build languages from Accept-Language header + let accept_language = header_sample + .get("accept-language") + .or_else(|| header_sample.get("Accept-Language")) + .cloned() + .unwrap_or_else(|| "en-US".to_string()); + + let languages: Vec = accept_language + .split(',') + .map(|s| s.split(';').next().unwrap_or(s).trim().to_string()) + .collect(); + + let language = languages + .first() + .cloned() + .unwrap_or_else(|| "en-US".to_string()); + + // Build navigator fingerprint + let navigator = NavigatorFingerprint { + user_agent: get_string(&parsed, "userAgent"), + user_agent_data: parsed + .get("userAgentData") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + do_not_track: parsed + .get("doNotTrack") + .and_then(|v| v.as_str().map(String::from)), + app_code_name: get_string_or(&parsed, "appCodeName", "Mozilla"), + app_name: get_string_or(&parsed, "appName", "Netscape"), + app_version: get_string(&parsed, "appVersion"), + oscpu: parsed + .get("oscpu") + .and_then(|v| v.as_str().map(String::from)), + webdriver: parsed + .get("webdriver") + .and_then(|v| v.as_str().map(String::from)), + language, + languages, + platform: get_string(&parsed, "platform"), + device_memory: parsed + .get("deviceMemory") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()), + hardware_concurrency: parsed + .get("hardwareConcurrency") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .unwrap_or(4), + product: get_string_or(&parsed, "product", "Gecko"), + product_sub: get_string(&parsed, "productSub"), + vendor: get_string(&parsed, "vendor"), + vendor_sub: get_string(&parsed, "vendorSub"), + max_touch_points: parsed + .get("maxTouchPoints") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0), + extra_properties: parsed + .get("extraProperties") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + }; + + // Build video card (will be filled later by WebGL sampler) + let video_card = parsed + .get("videoCard") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + // Build other components + let audio_codecs = parsed + .get("audioCodecs") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let video_codecs = parsed + .get("videoCodecs") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let plugins_data = parsed + .get("pluginsData") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let battery = parsed + .get("battery") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + let multimedia_devices = parsed + .get("multimediaDevices") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let fonts = parsed + .get("fonts") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let fingerprint = Fingerprint { + screen, + navigator, + video_codecs, + audio_codecs, + plugins_data, + battery, + video_card, + multimedia_devices, + fonts, + mock_web_rtc: options.mock_web_rtc, + slim: options.slim, + }; + + // Build headers (filter out internal nodes and missing values) + let headers: Headers = header_sample + .iter() + .filter(|(k, v)| !k.starts_with('*') && *v != MISSING_VALUE_DATASET_TOKEN) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + // Order headers + let ordered_headers = self.order_headers(&headers, &fingerprint.navigator.user_agent); + + Ok(FingerprintWithHeaders { + fingerprint, + headers: ordered_headers, + }) + } + + /// Order headers according to browser-specific ordering. + fn order_headers(&self, headers: &Headers, user_agent: &str) -> Headers { + let browser = detect_browser_from_ua(user_agent); + let order = self.headers_order.get(browser).cloned().unwrap_or_default(); + + let mut ordered = HashMap::new(); + + // Add headers in order + for header_name in &order { + if let Some(value) = headers.get(header_name) { + ordered.insert(header_name.clone(), value.clone()); + } + } + + // Add remaining headers not in order + for (key, value) in headers { + if !order.contains(key) { + ordered.insert(key.clone(), value.clone()); + } + } + + ordered + } +} + +fn get_string(map: &HashMap, key: &str) -> String { + map + .get(key) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default() +} + +fn get_string_or(map: &HashMap, key: &str, default: &str) -> String { + map + .get(key) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| default.to_string()) +} + +fn detect_browser_from_ua(user_agent: &str) -> &str { + let ua_lower = user_agent.to_lowercase(); + if ua_lower.contains("firefox") { + "firefox" + } else if ua_lower.contains("edg/") || ua_lower.contains("edge") { + "edge" + } else if ua_lower.contains("chrome") { + "chrome" + } else if ua_lower.contains("safari") { + "safari" + } else { + "chrome" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_generator() { + let generator = FingerprintGenerator::new(); + assert!( + generator.is_ok(), + "Failed to create generator: {:?}", + generator.err() + ); + } + + #[test] + fn test_generate_fingerprint() { + let generator = FingerprintGenerator::new().unwrap(); + let options = FingerprintOptions::default(); + + let result = generator.get_fingerprint(&options); + assert!( + result.is_ok(), + "Failed to generate fingerprint: {:?}", + result.err() + ); + + if let Ok(fp) = result { + assert!(!fp.fingerprint.navigator.user_agent.is_empty()); + assert!(fp.fingerprint.screen.width > 0); + assert!(fp.fingerprint.screen.height > 0); + } + } + + #[test] + fn test_generate_firefox_fingerprint() { + let generator = FingerprintGenerator::new().unwrap(); + let options = FingerprintOptions { + browsers: Some(vec!["firefox".to_string()]), + ..Default::default() + }; + + let result = generator.get_fingerprint(&options); + assert!(result.is_ok(), "Failed to generate Firefox fingerprint"); + + if let Ok(fp) = result { + assert!( + fp.fingerprint + .navigator + .user_agent + .to_lowercase() + .contains("firefox"), + "User agent should contain Firefox: {}", + fp.fingerprint.navigator.user_agent + ); + } + } + + #[test] + fn test_generate_with_screen_constraints() { + let generator = FingerprintGenerator::new().unwrap(); + let options = FingerprintOptions { + screen: Some(ScreenConstraints { + min_width: Some(1900), + max_width: Some(1920), + min_height: Some(1000), + max_height: Some(1100), + }), + ..Default::default() + }; + + let result = generator.get_fingerprint(&options); + assert!( + result.is_ok(), + "Failed to generate fingerprint with screen constraints" + ); + + if let Ok(fp) = result { + assert!( + fp.fingerprint.screen.width >= 1900 && fp.fingerprint.screen.width <= 1920, + "Screen width {} should be between 1900 and 1920", + fp.fingerprint.screen.width + ); + } + } + + #[test] + fn test_browser_http_info_parse() { + let info = BrowserHttpInfo::parse("chrome/143.0.0.0|2"); + assert!(info.is_some()); + let info = info.unwrap(); + assert_eq!(info.name, "chrome"); + assert_eq!(info.major_version(), 143); + assert_eq!(info.http_version, "2"); + } +} diff --git a/src-tauri/src/camoufox/fingerprint/types.rs b/src-tauri/src/camoufox/fingerprint/types.rs new file mode 100644 index 0000000..e348ef4 --- /dev/null +++ b/src-tauri/src/camoufox/fingerprint/types.rs @@ -0,0 +1,302 @@ +//! Fingerprint type definitions. +//! +//! These types represent browser fingerprints that can be injected into Camoufox. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A complete browser fingerprint. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Fingerprint { + pub screen: ScreenFingerprint, + pub navigator: NavigatorFingerprint, + #[serde(default)] + pub video_codecs: HashMap, + #[serde(default)] + pub audio_codecs: HashMap, + #[serde(default)] + pub plugins_data: HashMap, + #[serde(default)] + pub battery: Option, + pub video_card: VideoCard, + #[serde(default)] + pub multimedia_devices: Vec, + #[serde(default)] + pub fonts: Vec, + #[serde(default)] + pub mock_web_rtc: bool, + #[serde(default)] + pub slim: bool, +} + +/// Screen-related fingerprint properties. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ScreenFingerprint { + pub width: u32, + pub height: u32, + pub avail_width: u32, + pub avail_height: u32, + #[serde(default)] + pub avail_top: u32, + #[serde(default)] + pub avail_left: u32, + pub color_depth: u32, + pub pixel_depth: u32, + #[serde(default = "default_device_pixel_ratio")] + pub device_pixel_ratio: f64, + #[serde(default)] + pub page_x_offset: f64, + #[serde(default)] + pub page_y_offset: f64, + pub inner_width: u32, + pub inner_height: u32, + pub outer_width: u32, + pub outer_height: u32, + #[serde(default)] + pub screen_x: i32, + #[serde(default)] + pub screen_y: i32, + #[serde(default)] + pub client_width: Option, + #[serde(default)] + pub client_height: Option, + #[serde(default)] + pub has_hdr: bool, +} + +fn default_device_pixel_ratio() -> f64 { + 1.0 +} + +/// Brand information for User-Agent Client Hints. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Brand { + pub brand: String, + pub version: String, +} + +/// User-Agent Client Hints data. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct UserAgentData { + #[serde(default)] + pub brands: Vec, + #[serde(default)] + pub mobile: bool, + #[serde(default)] + pub platform: String, + #[serde(default)] + pub architecture: String, + #[serde(default)] + pub bitness: String, + #[serde(default)] + pub full_version_list: Vec, + #[serde(default)] + pub model: String, + #[serde(default)] + pub platform_version: String, + #[serde(default)] + pub ua_full_version: String, +} + +/// Extra navigator properties. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ExtraProperties { + #[serde(default)] + pub vendor_flavors: Vec, + #[serde(default)] + pub is_bluetooth_supported: bool, + #[serde(default)] + pub global_privacy_control: Option, + #[serde(default = "default_pdf_viewer_enabled")] + pub pdf_viewer_enabled: bool, + #[serde(default)] + pub installed_apps: Vec, +} + +fn default_pdf_viewer_enabled() -> bool { + true +} + +/// Navigator-related fingerprint properties. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct NavigatorFingerprint { + pub user_agent: String, + #[serde(default)] + pub user_agent_data: Option, + #[serde(default)] + pub do_not_track: Option, + #[serde(default = "default_app_code_name")] + pub app_code_name: String, + #[serde(default = "default_app_name")] + pub app_name: String, + #[serde(default)] + pub app_version: String, + #[serde(default)] + pub oscpu: Option, + #[serde(default)] + pub webdriver: Option, + pub language: String, + pub languages: Vec, + pub platform: String, + #[serde(default)] + pub device_memory: Option, + pub hardware_concurrency: u32, + #[serde(default = "default_product")] + pub product: String, + #[serde(default)] + pub product_sub: String, + #[serde(default)] + pub vendor: String, + #[serde(default)] + pub vendor_sub: String, + #[serde(default)] + pub max_touch_points: u32, + #[serde(default)] + pub extra_properties: Option, +} + +fn default_app_code_name() -> String { + "Mozilla".to_string() +} + +fn default_app_name() -> String { + "Netscape".to_string() +} + +fn default_product() -> String { + "Gecko".to_string() +} + +/// WebGL video card information. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VideoCard { + pub vendor: String, + pub renderer: String, +} + +/// Battery status fingerprint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BatteryFingerprint { + pub charging: bool, + pub charging_time: f64, + pub discharging_time: f64, + pub level: f64, +} + +/// HTTP headers for a fingerprint. +pub type Headers = HashMap; + +/// A fingerprint combined with matching HTTP headers. +#[derive(Debug, Clone)] +pub struct FingerprintWithHeaders { + pub fingerprint: Fingerprint, + pub headers: Headers, +} + +/// Options for generating fingerprints. +#[derive(Debug, Clone, Default)] +pub struct FingerprintOptions { + /// Target operating system: "windows", "macos", "linux" + pub operating_system: Option, + /// Target browser: "firefox", "chrome", "safari", "edge" + pub browsers: Option>, + /// Target device type: "desktop", "mobile" + pub devices: Option>, + /// Locales for Accept-Language header + pub locales: Option>, + /// HTTP version: "1" or "2" + pub http_version: Option, + /// Screen dimension constraints + pub screen: Option, + /// Whether to mock WebRTC + pub mock_web_rtc: bool, + /// Slim mode (fewer evasions) + pub slim: bool, +} + +/// Constraints for screen dimensions. +#[derive(Debug, Clone, Default)] +pub struct ScreenConstraints { + pub min_width: Option, + pub max_width: Option, + pub min_height: Option, + pub max_height: Option, +} + +impl ScreenConstraints { + pub fn new() -> Self { + Self::default() + } + + pub fn with_min_width(mut self, width: u32) -> Self { + self.min_width = Some(width); + self + } + + pub fn with_max_width(mut self, width: u32) -> Self { + self.max_width = Some(width); + self + } + + pub fn with_min_height(mut self, height: u32) -> Self { + self.min_height = Some(height); + self + } + + pub fn with_max_height(mut self, height: u32) -> Self { + self.max_height = Some(height); + self + } + + /// Check if a screen size matches these constraints. + pub fn matches(&self, width: u32, height: u32) -> bool { + if let Some(min_w) = self.min_width { + if width < min_w { + return false; + } + } + if let Some(max_w) = self.max_width { + if width > max_w { + return false; + } + } + if let Some(min_h) = self.min_height { + if height < min_h { + return false; + } + } + if let Some(max_h) = self.max_height { + if height > max_h { + return false; + } + } + true + } +} + +/// Constants used in fingerprint generation. +pub const MISSING_VALUE_DATASET_TOKEN: &str = "*MISSING_VALUE*"; +pub const STRINGIFIED_PREFIX: &str = "*STRINGIFIED*"; + +/// Special node names in the Bayesian networks. +pub const BROWSER_HTTP_NODE_NAME: &str = "*BROWSER_HTTP"; +pub const OPERATING_SYSTEM_NODE_NAME: &str = "*OPERATING_SYSTEM"; +pub const DEVICE_NODE_NAME: &str = "*DEVICE"; + +/// Supported browsers. +pub const SUPPORTED_BROWSERS: &[&str] = &["chrome", "firefox", "safari", "edge"]; + +/// Supported operating systems. +pub const SUPPORTED_OPERATING_SYSTEMS: &[&str] = &["windows", "macos", "linux", "android", "ios"]; + +/// Supported devices. +pub const SUPPORTED_DEVICES: &[&str] = &["desktop", "mobile"]; + +/// Supported HTTP versions. +pub const SUPPORTED_HTTP_VERSIONS: &[&str] = &["1", "2"]; diff --git a/src-tauri/src/camoufox/fonts.rs b/src-tauri/src/camoufox/fonts.rs new file mode 100644 index 0000000..2a4c2b5 --- /dev/null +++ b/src-tauri/src/camoufox/fonts.rs @@ -0,0 +1,83 @@ +//! OS-specific font lists for Camoufox. +//! +//! Provides default system fonts for Windows, macOS, and Linux. + +use std::collections::HashMap; + +use crate::camoufox::data; + +/// Get fonts for the target OS. +pub fn get_fonts_for_os(target_os: &str) -> Vec { + let fonts_map: HashMap> = + serde_json::from_str(data::FONTS_JSON).unwrap_or_default(); + + let os_key = match target_os { + "win" | "windows" => "win", + "mac" | "macos" => "mac", + "lin" | "linux" => "lin", + _ => "win", // Default to Windows fonts + }; + + fonts_map.get(os_key).cloned().unwrap_or_default() +} + +/// Get fonts for the target OS with additional custom fonts. +pub fn get_fonts_with_custom(target_os: &str, custom_fonts: Option<&[String]>) -> Vec { + let mut fonts = get_fonts_for_os(target_os); + + if let Some(custom) = custom_fonts { + // Add custom fonts, avoiding duplicates + for font in custom { + if !fonts.contains(font) { + fonts.push(font.clone()); + } + } + } + + fonts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_fonts_for_windows() { + let fonts = get_fonts_for_os("win"); + assert!(!fonts.is_empty()); + assert!(fonts.contains(&"Arial".to_string())); + assert!(fonts.contains(&"Calibri".to_string())); + } + + #[test] + fn test_get_fonts_for_macos() { + let fonts = get_fonts_for_os("mac"); + assert!(!fonts.is_empty()); + assert!(fonts.contains(&"Helvetica".to_string())); + } + + #[test] + fn test_get_fonts_for_linux() { + let fonts = get_fonts_for_os("lin"); + assert!(!fonts.is_empty()); + } + + #[test] + fn test_get_fonts_with_custom() { + let custom = vec!["MyCustomFont".to_string()]; + let fonts = get_fonts_with_custom("win", Some(&custom)); + + assert!(fonts.contains(&"MyCustomFont".to_string())); + assert!(fonts.contains(&"Arial".to_string())); + } + + #[test] + fn test_fonts_no_duplicates() { + let custom = vec!["Arial".to_string()]; // Arial already exists in Windows fonts + let fonts = get_fonts_with_custom("win", Some(&custom)); + + // Count occurrences of Arial + let arial_count = fonts.iter().filter(|f| *f == "Arial").count(); + assert_eq!(arial_count, 1); + } +} diff --git a/src-tauri/src/camoufox/geolocation.rs b/src-tauri/src/camoufox/geolocation.rs new file mode 100644 index 0000000..ac56363 --- /dev/null +++ b/src-tauri/src/camoufox/geolocation.rs @@ -0,0 +1,541 @@ +//! Geolocation support for Camoufox fingerprinting. +//! +//! This module provides IP-based geolocation lookup using the MaxMind GeoLite2 database, +//! and locale generation based on country/territory information. + +use crate::camoufox::data; +use crate::geoip_downloader::GeoIPDownloader; +use directories::BaseDirs; +use maxminddb::{geoip2, Reader}; +use quick_xml::events::Event; +use quick_xml::Reader as XmlReader; +use rand::Rng; +use std::collections::HashMap; +use std::net::IpAddr; +use std::path::PathBuf; +use std::str::FromStr; + +/// Geolocation error type. +#[derive(Debug, thiserror::Error)] +pub enum GeolocationError { + #[error("GeoIP database not found. Please download it first.")] + DatabaseNotFound, + + #[error("Failed to open GeoIP database: {0}")] + DatabaseOpen(String), + + #[error("Invalid IP address: {0}")] + InvalidIP(String), + + #[error("IP location not found: {0}")] + LocationNotFound(String), + + #[error("Unknown territory: {0}")] + UnknownTerritory(String), + + #[error("No language data for territory: {0}")] + NoLanguageData(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Network error: {0}")] + Network(String), +} + +/// Locale information. +#[derive(Debug, Clone)] +pub struct Locale { + pub language: String, + pub region: Option, + pub script: Option, +} + +impl Locale { + /// Format locale as a string (e.g., "en-US"). + pub fn as_string(&self) -> String { + if let Some(region) = &self.region { + format!("{}-{}", self.language, region) + } else { + self.language.clone() + } + } + + /// Convert to config format for Camoufox. + pub fn as_config(&self) -> HashMap { + let mut config = HashMap::new(); + + if let Some(region) = &self.region { + config.insert( + "locale:region".to_string(), + serde_json::json!(region.to_uppercase()), + ); + } + + config.insert( + "locale:language".to_string(), + serde_json::json!(self.language.clone()), + ); + + if let Some(script) = &self.script { + config.insert("locale:script".to_string(), serde_json::json!(script)); + } + + config + } +} + +/// Geolocation information. +#[derive(Debug, Clone)] +pub struct Geolocation { + pub locale: Locale, + pub longitude: f64, + pub latitude: f64, + pub timezone: String, + pub accuracy: Option, +} + +impl Geolocation { + /// Convert to config format for Camoufox. + pub fn as_config(&self) -> HashMap { + let mut config = self.locale.as_config(); + + config.insert( + "geolocation:longitude".to_string(), + serde_json::json!(self.longitude), + ); + config.insert( + "geolocation:latitude".to_string(), + serde_json::json!(self.latitude), + ); + config.insert("timezone".to_string(), serde_json::json!(self.timezone)); + + if let Some(accuracy) = self.accuracy { + config.insert( + "geolocation:accuracy".to_string(), + serde_json::json!(accuracy), + ); + } + + config + } +} + +/// Territory language population data. +struct LanguagePopulation { + language: String, + population_percent: f64, +} + +/// Statistical locale selector based on territory language populations. +pub struct LocaleSelector { + territories: HashMap>, +} + +impl LocaleSelector { + /// Create a new locale selector by parsing territory info XML. + pub fn new() -> Result { + let mut territories: HashMap> = HashMap::new(); + + let mut reader = XmlReader::from_str(data::TERRITORY_INFO_XML); + reader.config_mut().trim_text(true); + + let mut current_territory: Option = None; + let mut current_languages: Vec = Vec::new(); + + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + let name = e.name(); + let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); + + if name_str == "territory" { + // Save previous territory if exists + if let Some(code) = current_territory.take() { + if !current_languages.is_empty() { + territories.insert(code, std::mem::take(&mut current_languages)); + } + } + + // Get territory type attribute + for attr in e.attributes().flatten() { + if attr.key.as_ref() == b"type" { + current_territory = Some(String::from_utf8_lossy(&attr.value).to_uppercase()); + } + } + } else if name_str == "languagePopulation" && current_territory.is_some() { + let mut lang_type = None; + let mut pop_percent = 0.0; + + for attr in e.attributes().flatten() { + match attr.key.as_ref() { + b"type" => { + lang_type = Some(String::from_utf8_lossy(&attr.value).to_string()); + } + b"populationPercent" => { + pop_percent = String::from_utf8_lossy(&attr.value).parse().unwrap_or(0.0); + } + _ => {} + } + } + + if let Some(lang) = lang_type { + current_languages.push(LanguagePopulation { + language: lang.replace('_', "-"), + population_percent: pop_percent, + }); + } + } + } + Ok(Event::End(ref e)) => { + let name_ref = e.name(); + let name = std::str::from_utf8(name_ref.as_ref()).unwrap_or(""); + if name == "territory" { + // Save territory + if let Some(code) = current_territory.take() { + if !current_languages.is_empty() { + territories.insert(code, std::mem::take(&mut current_languages)); + } + } + } + } + Ok(Event::Eof) => break, + Err(e) => { + log::warn!("Error parsing territory XML: {}", e); + break; + } + _ => {} + } + buf.clear(); + } + + Ok(Self { territories }) + } + + /// Get a locale for a given region/country code. + pub fn from_region(&self, region: &str) -> Result { + let region_upper = region.to_uppercase(); + + let languages = self + .territories + .get(®ion_upper) + .ok_or_else(|| GeolocationError::UnknownTerritory(region.to_string()))?; + + if languages.is_empty() { + return Err(GeolocationError::NoLanguageData(region.to_string())); + } + + // Weighted random selection based on population percentage + let total: f64 = languages.iter().map(|l| l.population_percent).sum(); + let mut rng = rand::rng(); + let target = rng.random::() * total; + let mut cumulative = 0.0; + + for lang in languages { + cumulative += lang.population_percent; + if cumulative >= target { + return Ok(normalize_locale(&format!( + "{}-{}", + lang.language, region_upper + ))); + } + } + + // Fallback to first language + let first_lang = &languages[0].language; + Ok(normalize_locale(&format!( + "{}-{}", + first_lang, region_upper + ))) + } +} + +impl Default for LocaleSelector { + fn default() -> Self { + Self::new().unwrap_or(Self { + territories: HashMap::new(), + }) + } +} + +/// Normalize a locale string to standard format. +fn normalize_locale(locale: &str) -> Locale { + let parts: Vec<&str> = locale.split('-').collect(); + + let language = parts + .first() + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| "en".to_string()); + + let region = parts.get(1).map(|s| s.to_uppercase()); + + // Determine script based on language if needed + let script = match language.as_str() { + "zh" => { + // Chinese - Traditional for TW/HK, Simplified otherwise + if region.as_deref() == Some("TW") || region.as_deref() == Some("HK") { + Some("Hant".to_string()) + } else { + Some("Hans".to_string()) + } + } + "sr" => { + // Serbian - can be Cyrillic or Latin + Some("Cyrl".to_string()) + } + _ => None, + }; + + Locale { + language, + region, + script, + } +} + +/// Get the path to the GeoIP MMDB file. +fn get_mmdb_path() -> Result { + let base_dirs = BaseDirs::new().ok_or(GeolocationError::DatabaseNotFound)?; + + #[cfg(target_os = "windows")] + let cache_dir = base_dirs + .data_local_dir() + .join("camoufox") + .join("camoufox") + .join("Cache"); + + #[cfg(target_os = "macos")] + let cache_dir = base_dirs.cache_dir().join("camoufox"); + + #[cfg(target_os = "linux")] + let cache_dir = base_dirs.cache_dir().join("camoufox"); + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + let cache_dir = base_dirs.cache_dir().join("camoufox"); + + Ok(cache_dir.join("GeoLite2-City.mmdb")) +} + +/// Check if the GeoIP database is available. +pub fn is_geoip_available() -> bool { + GeoIPDownloader::is_geoip_database_available() +} + +/// Get geolocation information for an IP address. +pub fn get_geolocation(ip: &str) -> Result { + let mmdb_path = get_mmdb_path()?; + + if !mmdb_path.exists() { + return Err(GeolocationError::DatabaseNotFound); + } + + let reader = + Reader::open_readfile(&mmdb_path).map_err(|e| GeolocationError::DatabaseOpen(e.to_string()))?; + + let ip_addr: IpAddr = + IpAddr::from_str(ip).map_err(|_| GeolocationError::InvalidIP(ip.to_string()))?; + + let city: geoip2::City = reader + .lookup(ip_addr) + .map_err(|e| GeolocationError::LocationNotFound(e.to_string()))?; + + // Extract location data + let location = city + .location + .ok_or_else(|| GeolocationError::LocationNotFound(ip.to_string()))?; + + let longitude = location + .longitude + .ok_or_else(|| GeolocationError::LocationNotFound("No longitude".to_string()))?; + let latitude = location + .latitude + .ok_or_else(|| GeolocationError::LocationNotFound("No latitude".to_string()))?; + let timezone = location + .time_zone + .ok_or_else(|| GeolocationError::LocationNotFound("No timezone".to_string()))? + .to_string(); + + // Get country code + let country = city + .country + .ok_or_else(|| GeolocationError::LocationNotFound("No country".to_string()))?; + let iso_code = country + .iso_code + .ok_or_else(|| GeolocationError::LocationNotFound("No country code".to_string()))? + .to_uppercase(); + + // Get locale from territory data + let selector = LocaleSelector::new()?; + let locale = selector.from_region(&iso_code)?; + + Ok(Geolocation { + locale, + longitude, + latitude, + timezone, + accuracy: location.accuracy_radius.map(|r| r as f64), + }) +} + +/// Validate an IP address (IPv4 or IPv6). +pub fn validate_ip(ip: &str) -> bool { + IpAddr::from_str(ip).is_ok() +} + +/// Check if an IP is IPv4. +pub fn is_ipv4(ip: &str) -> bool { + if let Ok(addr) = IpAddr::from_str(ip) { + addr.is_ipv4() + } else { + false + } +} + +/// Check if an IP is IPv6. +pub fn is_ipv6(ip: &str) -> bool { + if let Ok(addr) = IpAddr::from_str(ip) { + addr.is_ipv6() + } else { + false + } +} + +/// Fetch public IP address, optionally through a proxy. +pub async fn fetch_public_ip(proxy: Option<&str>) -> Result { + let urls = [ + "https://api.ipify.org", + "https://checkip.amazonaws.com", + "https://ipinfo.io/ip", + "https://icanhazip.com", + "https://ifconfig.co/ip", + "https://ipecho.net/plain", + ]; + + let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(5)); + + let client = if let Some(proxy_url) = proxy { + let proxy = reqwest::Proxy::all(proxy_url) + .map_err(|e| GeolocationError::Network(format!("Invalid proxy: {}", e)))?; + client_builder + .proxy(proxy) + .build() + .map_err(|e| GeolocationError::Network(e.to_string()))? + } else { + client_builder + .build() + .map_err(|e| GeolocationError::Network(e.to_string()))? + }; + + let mut last_error = None; + + for url in &urls { + match client.get(*url).send().await { + Ok(response) if response.status().is_success() => match response.text().await { + Ok(text) => { + let ip = text.trim().to_string(); + if validate_ip(&ip) { + return Ok(ip); + } + } + Err(e) => { + last_error = Some(format!("Failed to read response from {}: {}", url, e)); + } + }, + Ok(response) => { + last_error = Some(format!("HTTP {} from {}", response.status(), url)); + } + Err(e) => { + last_error = Some(format!("Request to {} failed: {}", url, e)); + } + } + } + + Err(GeolocationError::Network(last_error.unwrap_or_else(|| { + "Failed to fetch public IP from any endpoint".to_string() + }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_locale_selector_creation() { + let selector = LocaleSelector::new(); + assert!(selector.is_ok()); + } + + #[test] + fn test_locale_from_region() { + let selector = LocaleSelector::new().unwrap(); + + // Test common regions + let us_locale = selector.from_region("US"); + assert!(us_locale.is_ok()); + let us = us_locale.unwrap(); + assert_eq!(us.region, Some("US".to_string())); + + let de_locale = selector.from_region("DE"); + assert!(de_locale.is_ok()); + let de = de_locale.unwrap(); + assert_eq!(de.region, Some("DE".to_string())); + } + + #[test] + fn test_locale_as_string() { + let locale = Locale { + language: "en".to_string(), + region: Some("US".to_string()), + script: None, + }; + assert_eq!(locale.as_string(), "en-US"); + + let locale_no_region = Locale { + language: "en".to_string(), + region: None, + script: None, + }; + assert_eq!(locale_no_region.as_string(), "en"); + } + + #[test] + fn test_validate_ip() { + assert!(validate_ip("8.8.8.8")); + assert!(validate_ip("192.168.1.1")); + assert!(validate_ip("2001:4860:4860::8888")); + assert!(!validate_ip("invalid")); + assert!(!validate_ip("256.256.256.256")); + } + + #[test] + fn test_is_ipv4() { + assert!(is_ipv4("8.8.8.8")); + assert!(!is_ipv4("2001:4860:4860::8888")); + assert!(!is_ipv4("invalid")); + } + + #[test] + fn test_is_ipv6() { + assert!(is_ipv6("2001:4860:4860::8888")); + assert!(!is_ipv6("8.8.8.8")); + assert!(!is_ipv6("invalid")); + } + + #[test] + fn test_normalize_locale() { + let locale = normalize_locale("en-US"); + assert_eq!(locale.language, "en"); + assert_eq!(locale.region, Some("US".to_string())); + assert!(locale.script.is_none()); + + let zh_tw = normalize_locale("zh-TW"); + assert_eq!(zh_tw.language, "zh"); + assert_eq!(zh_tw.region, Some("TW".to_string())); + assert_eq!(zh_tw.script, Some("Hant".to_string())); + + let zh_cn = normalize_locale("zh-CN"); + assert_eq!(zh_cn.script, Some("Hans".to_string())); + } +} diff --git a/src-tauri/src/camoufox/launcher.rs b/src-tauri/src/camoufox/launcher.rs new file mode 100644 index 0000000..99c13be --- /dev/null +++ b/src-tauri/src/camoufox/launcher.rs @@ -0,0 +1,338 @@ +//! Camoufox browser launcher using playwright-rust. +//! +//! Provides functionality to launch Camoufox browser instances with fingerprint injection. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use playwright::api::{Browser, BrowserContext, Playwright, ProxySettings}; +use playwright::Error as PlaywrightError; + +use crate::camoufox::config::{CamoufoxConfigBuilder, CamoufoxLaunchConfig, ProxyConfig}; +use crate::camoufox::fingerprint::types::{Fingerprint, ScreenConstraints}; + +/// Camoufox launcher for creating browser instances. +pub struct CamoufoxLauncher { + playwright: Arc, + executable_path: PathBuf, +} + +/// Error type for launcher operations. +#[derive(Debug, thiserror::Error)] +pub enum LauncherError { + #[error("Playwright error: {0}")] + Playwright(PlaywrightError), + + #[error("Playwright Arc error: {0}")] + PlaywrightArc(#[from] Arc), + + #[error("Configuration error: {0}")] + Config(#[from] crate::camoufox::config::ConfigError), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Camoufox executable not found at: {0}")] + ExecutableNotFound(PathBuf), + + #[error("Failed to generate environment variables: {0}")] + EnvVars(#[from] serde_json::Error), +} + +/// Options for launching Camoufox. +#[derive(Debug, Clone, Default)] +pub struct LaunchOptions { + /// Operating system to spoof: "windows", "macos", "linux" + pub os: Option, + /// Block all images + pub block_images: bool, + /// Block WebRTC entirely + pub block_webrtc: bool, + /// Block WebGL (not recommended unless necessary) + pub block_webgl: bool, + /// Screen dimension constraints + pub screen: Option, + /// Fixed window size [width, height] + pub window: Option<(u32, u32)>, + /// Custom fingerprint (if not provided, one will be generated) + pub fingerprint: Option, + /// Run in headless mode + pub headless: bool, + /// Custom fonts to load + pub fonts: Option>, + /// Only use custom fonts (disable OS fonts) + pub custom_fonts_only: bool, + /// Firefox user preferences + pub firefox_user_prefs: Option>, + /// Proxy configuration + pub proxy: Option, + /// Additional browser arguments + pub args: Option>, + /// Additional environment variables + pub env: Option>, + /// Profile/user data directory + pub user_data_dir: Option, + /// Enable debug output + pub debug: bool, +} + +impl CamoufoxLauncher { + /// Create a new Camoufox launcher. + pub async fn new(executable_path: impl AsRef) -> Result { + let executable_path = executable_path.as_ref().to_path_buf(); + + if !executable_path.exists() { + return Err(LauncherError::ExecutableNotFound(executable_path)); + } + + let playwright = Playwright::initialize() + .await + .map_err(LauncherError::Playwright)?; + + Ok(Self { + playwright: Arc::new(playwright), + executable_path, + }) + } + + /// Launch a new Camoufox browser instance. + pub async fn launch(&self, options: LaunchOptions) -> Result { + let config = self.build_config(&options)?; + + if options.debug { + log::debug!("Camoufox config: {:?}", config.fingerprint_config); + } + + // Get environment variables + let env_vars = config.get_env_vars()?; + + // Build launch arguments + let mut args = options.args.clone().unwrap_or_default(); + + // Add headless flag if needed + if options.headless { + args.push("--headless".to_string()); + } + + // Merge environment variables + let mut env = options.env.clone().unwrap_or_default(); + for (key, value) in env_vars { + env.insert(key, value); + } + + // Handle fontconfig on Linux + if cfg!(target_os = "linux") { + if let Some(fontconfig_path) = + crate::camoufox::env_vars::get_fontconfig_env(&config.target_os, &self.executable_path) + { + env.insert("FONTCONFIG_PATH".to_string(), fontconfig_path); + } + } + + // Build Firefox user prefs + let mut firefox_prefs = config.firefox_prefs.clone(); + if let Some(user_prefs) = options.firefox_user_prefs { + for (key, value) in user_prefs { + firefox_prefs.insert(key, value); + } + } + + // Get the Firefox browser type + let firefox = self.playwright.firefox(); + + // Build launch options + let mut launch_options = firefox.launcher(); + launch_options = launch_options.executable(&self.executable_path); + launch_options = launch_options.headless(options.headless); + + // Add args + if !args.is_empty() { + launch_options = launch_options.args(&args); + } + + // Add environment as serde_json::Map + if !env.is_empty() { + let env_map: serde_json::Map = env + .into_iter() + .map(|(k, v)| (k, serde_json::Value::String(v))) + .collect(); + launch_options = launch_options.env(env_map); + } + + // Add proxy if configured + if let Some(proxy) = &config.proxy { + let proxy_settings = ProxySettings { + server: proxy.server.clone(), + username: proxy.username.clone(), + password: proxy.password.clone(), + bypass: proxy.bypass.clone(), + }; + launch_options = launch_options.proxy(proxy_settings); + } + + // Add Firefox preferences + if !firefox_prefs.is_empty() { + let prefs_map: serde_json::Map = + firefox_prefs.into_iter().collect(); + launch_options = launch_options.firefox_user_prefs(prefs_map); + } + + // Launch the browser + let browser = launch_options.launch().await?; + + Ok(browser) + } + + /// Launch a persistent browser context. + pub async fn launch_persistent_context( + &self, + user_data_dir: impl AsRef, + options: LaunchOptions, + ) -> Result { + let config = self.build_config(&options)?; + + if options.debug { + log::debug!("Camoufox config: {:?}", config.fingerprint_config); + } + + // Get environment variables + let env_vars = config.get_env_vars()?; + + // Build launch arguments + let mut args = options.args.clone().unwrap_or_default(); + + if options.headless { + args.push("--headless".to_string()); + } + + // Merge environment variables + let mut env = options.env.clone().unwrap_or_default(); + for (key, value) in env_vars { + env.insert(key, value); + } + + // Handle fontconfig on Linux + if cfg!(target_os = "linux") { + if let Some(fontconfig_path) = + crate::camoufox::env_vars::get_fontconfig_env(&config.target_os, &self.executable_path) + { + env.insert("FONTCONFIG_PATH".to_string(), fontconfig_path); + } + } + + // Build Firefox user prefs + let mut firefox_prefs = config.firefox_prefs.clone(); + if let Some(user_prefs) = options.firefox_user_prefs { + for (key, value) in user_prefs { + firefox_prefs.insert(key, value); + } + } + + // Get the Firefox browser type + let firefox = self.playwright.firefox(); + + // Build persistent context options + let mut context_options = firefox.persistent_context_launcher(user_data_dir.as_ref()); + context_options = context_options.executable(&self.executable_path); + context_options = context_options.headless(options.headless); + + // Add args + if !args.is_empty() { + context_options = context_options.args(&args); + } + + // Add environment as serde_json::Map + if !env.is_empty() { + let env_map: serde_json::Map = env + .into_iter() + .map(|(k, v)| (k, serde_json::Value::String(v))) + .collect(); + context_options = context_options.env(env_map); + } + + // Add proxy if configured + if let Some(proxy) = &config.proxy { + let proxy_settings = ProxySettings { + server: proxy.server.clone(), + username: proxy.username.clone(), + password: proxy.password.clone(), + bypass: proxy.bypass.clone(), + }; + context_options = context_options.proxy(proxy_settings); + } + + // Note: PersistentContextLauncher doesn't support firefox_user_prefs + // Firefox preferences should be set via about:config or prefs.js in the profile + + // Launch the persistent context + let context = context_options.launch().await?; + + Ok(context) + } + + /// Build Camoufox configuration from launch options. + fn build_config(&self, options: &LaunchOptions) -> Result { + let mut builder = CamoufoxConfigBuilder::new(); + + if let Some(os) = &options.os { + builder = builder.operating_system(os); + } + + if let Some(screen) = &options.screen { + builder = builder.screen_constraints(screen.clone()); + } + + if let Some(fingerprint) = &options.fingerprint { + builder = builder.fingerprint(fingerprint.clone()); + } + + builder = builder.block_images(options.block_images); + builder = builder.block_webrtc(options.block_webrtc); + builder = builder.block_webgl(options.block_webgl); + builder = builder.headless(options.headless); + + if let Some(fonts) = &options.fonts { + builder = builder.custom_fonts(fonts.clone()); + } + + builder = builder.custom_fonts_only(options.custom_fonts_only); + + if let Some(proxy) = &options.proxy { + builder = builder.proxy(proxy.clone()); + } + + // Get Firefox version from executable + if let Some(version) = crate::camoufox::config::get_firefox_version(&self.executable_path) { + builder = builder.ff_version(version); + } + + Ok(builder.build()?) + } + + /// Get the executable path. + pub fn executable_path(&self) -> &Path { + &self.executable_path + } +} + +/// Convenience function to launch Camoufox with default settings. +pub async fn launch_camoufox( + executable_path: impl AsRef, + options: LaunchOptions, +) -> Result { + let launcher = CamoufoxLauncher::new(executable_path).await?; + launcher.launch(options).await +} + +/// Convenience function to launch a persistent Camoufox context. +pub async fn launch_persistent_camoufox( + executable_path: impl AsRef, + user_data_dir: impl AsRef, + options: LaunchOptions, +) -> Result { + let launcher = CamoufoxLauncher::new(executable_path).await?; + launcher + .launch_persistent_context(user_data_dir, options) + .await +} diff --git a/src-tauri/src/camoufox/mod.rs b/src-tauri/src/camoufox/mod.rs new file mode 100644 index 0000000..43f0c38 --- /dev/null +++ b/src-tauri/src/camoufox/mod.rs @@ -0,0 +1,154 @@ +//! Camoufox browser integration module. +//! +//! Provides native Rust support for launching Camoufox browsers with realistic +//! fingerprint injection using playwright-rust. +//! +//! # Overview +//! +//! This module replaces the previous Node.js-based nodecar implementation with +//! a pure Rust solution. Key components: +//! +//! - **Fingerprint Generation**: Bayesian network-based fingerprint generation +//! - **WebGL Sampling**: Realistic WebGL configurations from a SQLite database +//! - **Configuration Builder**: Converts fingerprints to Camoufox config format +//! - **Launcher**: playwright-rust integration for browser launching +//! +//! # Example +//! +//! ```rust,ignore +//! use donutbrowser_lib::camoufox::{CamoufoxLauncher, LaunchOptions}; +//! +//! async fn launch_browser() -> Result<(), Box> { +//! let launcher = CamoufoxLauncher::new("/path/to/camoufox").await?; +//! +//! let options = LaunchOptions { +//! os: Some("windows".to_string()), +//! headless: false, +//! ..Default::default() +//! }; +//! +//! let browser = launcher.launch(options).await?; +//! +//! // Use the browser... +//! +//! browser.close().await?; +//! Ok(()) +//! } +//! ``` + +pub mod config; +pub mod data; +pub mod env_vars; +pub mod fingerprint; +pub mod fonts; +pub mod geolocation; +pub mod launcher; +pub mod webgl; + +// Re-export main types for convenience +pub use config::{ + CamoufoxConfigBuilder, CamoufoxLaunchConfig, ConfigError, GeoIPOption, ProxyConfig, +}; +pub use fingerprint::types::{ + Fingerprint, FingerprintOptions, FingerprintWithHeaders, NavigatorFingerprint, ScreenConstraints, + ScreenFingerprint, VideoCard, +}; +pub use fingerprint::{FingerprintError, FingerprintGenerator}; +pub use geolocation::{ + fetch_public_ip, get_geolocation, is_geoip_available, is_ipv4, is_ipv6, validate_ip, Geolocation, + GeolocationError, Locale, LocaleSelector, +}; +pub use launcher::{ + launch_camoufox, launch_persistent_camoufox, CamoufoxLauncher, LaunchOptions, LauncherError, +}; +pub use webgl::{sample_webgl, WebGLData, WebGLError}; + +/// Unified error type for all Camoufox operations. +#[derive(Debug, thiserror::Error)] +pub enum CamoufoxError { + #[error("Launcher error: {0}")] + Launcher(#[from] LauncherError), + + #[error("Configuration error: {0}")] + Config(#[from] ConfigError), + + #[error("Fingerprint error: {0}")] + Fingerprint(#[from] FingerprintError), + + #[error("WebGL error: {0}")] + WebGL(#[from] WebGLError), + + #[error("Geolocation error: {0}")] + Geolocation(#[from] GeolocationError), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fingerprint_generation() { + let generator = FingerprintGenerator::new().unwrap(); + let options = FingerprintOptions { + browsers: Some(vec!["firefox".to_string()]), + operating_system: Some("windows".to_string()), + ..Default::default() + }; + + let result = generator.get_fingerprint(&options); + assert!(result.is_ok()); + + let fp = result.unwrap(); + assert!(!fp.fingerprint.navigator.user_agent.is_empty()); + assert!(fp.fingerprint.screen.width > 0); + } + + #[test] + fn test_config_builder() { + let config = CamoufoxConfigBuilder::new() + .operating_system("windows") + .block_images(false) + .build(); + + assert!(config.is_ok()); + + let config = config.unwrap(); + assert!(!config.fingerprint_config.is_empty()); + assert!(config + .fingerprint_config + .contains_key("navigator.userAgent")); + } + + #[test] + fn test_webgl_sampling() { + let result = webgl::sample_webgl("win", None, None); + assert!(result.is_ok()); + + let webgl_data = result.unwrap(); + assert!(!webgl_data.vendor.is_empty()); + assert!(!webgl_data.renderer.is_empty()); + } + + #[test] + fn test_fonts() { + let fonts = fonts::get_fonts_for_os("win"); + assert!(!fonts.is_empty()); + assert!(fonts.contains(&"Arial".to_string())); + } + + #[test] + fn test_env_vars() { + let mut config = std::collections::HashMap::new(); + config.insert( + "navigator.userAgent".to_string(), + serde_json::json!("Mozilla/5.0"), + ); + + let env_vars = env_vars::config_to_env_vars(&config).unwrap(); + assert!(!env_vars.is_empty()); + assert!(env_vars.contains_key("CAMOU_CONFIG_1")); + } +} diff --git a/src-tauri/src/camoufox/webgl.rs b/src-tauri/src/camoufox/webgl.rs new file mode 100644 index 0000000..04b3a8a --- /dev/null +++ b/src-tauri/src/camoufox/webgl.rs @@ -0,0 +1,251 @@ +//! WebGL fingerprint sampling from SQLite database. +//! +//! Samples realistic WebGL configurations based on OS-specific probability distributions. + +use rand::Rng; +use rusqlite::{Connection, Result as SqliteResult}; +use std::collections::HashMap; +use std::io::Write; +use tempfile::NamedTempFile; + +use crate::camoufox::data; + +/// WebGL fingerprint data. +#[derive(Debug, Clone)] +pub struct WebGLData { + pub vendor: String, + pub renderer: String, + pub config: HashMap, +} + +/// Error type for WebGL operations. +#[derive(Debug, thiserror::Error)] +pub enum WebGLError { + #[error("SQLite error: {0}")] + Sqlite(#[from] rusqlite::Error), + + #[error("JSON parsing error: {0}")] + Json(#[from] serde_json::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("No WebGL data found for OS: {0}")] + NoDataForOS(String), + + #[error("Invalid vendor/renderer combination for OS {os}: {vendor}/{renderer}")] + InvalidCombination { + os: String, + vendor: String, + renderer: String, + }, +} + +/// Sample a WebGL configuration for the given OS. +/// +/// If `vendor` and `renderer` are provided, returns the specific configuration. +/// Otherwise, randomly samples based on OS-specific probability weights. +pub fn sample_webgl( + os: &str, + vendor: Option<&str>, + renderer: Option<&str>, +) -> Result { + // Write embedded database to a temporary file + let mut temp_file = NamedTempFile::new()?; + temp_file.write_all(data::WEBGL_DATA_DB)?; + let db_path = temp_file.path(); + + let conn = Connection::open(db_path)?; + + // Validate OS + let os_column = match os { + "win" | "windows" => "win", + "mac" | "macos" => "mac", + "lin" | "linux" => "lin", + _ => return Err(WebGLError::NoDataForOS(os.to_string())), + }; + + if let (Some(v), Some(r)) = (vendor, renderer) { + sample_specific(&conn, os_column, v, r) + } else { + sample_random(&conn, os_column) + } +} + +fn sample_specific( + conn: &Connection, + os_column: &str, + vendor: &str, + renderer: &str, +) -> Result { + let query = format!( + "SELECT vendor, renderer, data, {} FROM webgl_fingerprints WHERE vendor = ?1 AND renderer = ?2", + os_column + ); + + let mut stmt = conn.prepare(&query)?; + let mut rows = stmt.query([vendor, renderer])?; + + if let Some(row) = rows.next()? { + let weight: f64 = row.get(3)?; + if weight <= 0.0 { + return Err(WebGLError::InvalidCombination { + os: os_column.to_string(), + vendor: vendor.to_string(), + renderer: renderer.to_string(), + }); + } + + let data_json: String = row.get(2)?; + let config: HashMap = serde_json::from_str(&data_json)?; + + Ok(WebGLData { + vendor: vendor.to_string(), + renderer: renderer.to_string(), + config, + }) + } else { + Err(WebGLError::InvalidCombination { + os: os_column.to_string(), + vendor: vendor.to_string(), + renderer: renderer.to_string(), + }) + } +} + +fn sample_random(conn: &Connection, os_column: &str) -> Result { + let query = format!( + "SELECT vendor, renderer, data, {} FROM webgl_fingerprints WHERE {} > 0", + os_column, os_column + ); + + let mut stmt = conn.prepare(&query)?; + let rows: Vec<(String, String, String, f64)> = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, f64>(3)?, + )) + })? + .collect::>>()?; + + if rows.is_empty() { + return Err(WebGLError::NoDataForOS(os_column.to_string())); + } + + // Calculate total weight + let total_weight: f64 = rows.iter().map(|(_, _, _, w)| w).sum(); + + // Weighted random selection + let mut rng = rand::rng(); + let threshold = rng.random::() * total_weight; + let mut cumulative = 0.0; + + for (vendor, renderer, data_json, weight) in &rows { + cumulative += *weight; + if cumulative >= threshold { + let config: HashMap = serde_json::from_str(data_json)?; + return Ok(WebGLData { + vendor: vendor.clone(), + renderer: renderer.clone(), + config, + }); + } + } + + // Fallback to last row + let (vendor, renderer, data_json, _) = rows.last().unwrap(); + let config: HashMap = serde_json::from_str(data_json)?; + Ok(WebGLData { + vendor: vendor.clone(), + renderer: renderer.clone(), + config, + }) +} + +/// Get all possible vendor/renderer pairs for each OS. +pub fn get_possible_pairs() -> Result>, WebGLError> { + // Write embedded database to a temporary file + let mut temp_file = NamedTempFile::new()?; + temp_file.write_all(data::WEBGL_DATA_DB)?; + let db_path = temp_file.path(); + + let conn = Connection::open(db_path)?; + let mut result = HashMap::new(); + + for os in &["win", "mac", "lin"] { + let query = format!( + "SELECT DISTINCT vendor, renderer FROM webgl_fingerprints WHERE {} > 0 ORDER BY {} DESC", + os, os + ); + + let mut stmt = conn.prepare(&query)?; + let pairs: Vec<(String, String)> = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::>>()?; + + result.insert(os.to_string(), pairs); + } + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sample_webgl_windows() { + let result = sample_webgl("win", None, None); + assert!( + result.is_ok(), + "Failed to sample WebGL for Windows: {:?}", + result.err() + ); + + let data = result.unwrap(); + assert!(!data.vendor.is_empty()); + assert!(!data.renderer.is_empty()); + assert!(!data.config.is_empty()); + } + + #[test] + fn test_sample_webgl_macos() { + let result = sample_webgl("mac", None, None); + assert!( + result.is_ok(), + "Failed to sample WebGL for macOS: {:?}", + result.err() + ); + } + + #[test] + fn test_sample_webgl_linux() { + let result = sample_webgl("lin", None, None); + assert!( + result.is_ok(), + "Failed to sample WebGL for Linux: {:?}", + result.err() + ); + } + + #[test] + fn test_get_possible_pairs() { + let result = get_possible_pairs(); + assert!( + result.is_ok(), + "Failed to get possible pairs: {:?}", + result.err() + ); + + let pairs = result.unwrap(); + assert!(pairs.contains_key("win")); + assert!(pairs.contains_key("mac")); + assert!(pairs.contains_key("lin")); + assert!(!pairs.get("win").unwrap().is_empty()); + } +} diff --git a/src-tauri/src/camoufox_manager.rs b/src-tauri/src/camoufox_manager.rs index 1da4296..e05910e 100644 --- a/src-tauri/src/camoufox_manager.rs +++ b/src-tauri/src/camoufox_manager.rs @@ -1,12 +1,14 @@ use crate::browser_runner::BrowserRunner; +use crate::camoufox::{CamoufoxConfigBuilder, GeoIPOption, ScreenConstraints}; use crate::profile::BrowserProfile; use directories::BaseDirs; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; +use std::process::Stdio; use std::sync::Arc; use tauri::AppHandle; -use tauri_plugin_shell::ShellExt; +use tokio::process::Command as TokioCommand; use tokio::sync::Mutex as AsyncMutex; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -86,7 +88,7 @@ impl CamoufoxManager { } pub fn instance() -> &'static CamoufoxManager { - &CAMOUFOX_NODECAR_LAUNCHER + &CAMOUFOX_LAUNCHER } pub fn get_profiles_dir(&self) -> PathBuf { @@ -103,112 +105,91 @@ impl CamoufoxManager { /// Generate Camoufox fingerprint configuration during profile creation pub async fn generate_fingerprint_config( &self, - app_handle: &AppHandle, + _app_handle: &AppHandle, profile: &crate::profile::BrowserProfile, config: &CamoufoxConfig, ) -> Result> { - let mut config_args = vec!["camoufox".to_string(), "generate-config".to_string()]; - - // Always ensure executable_path is set to the user's binary location + // Get executable path let executable_path = if let Some(path) = &config.executable_path { - path.clone() + PathBuf::from(path) } else { - // Use the browser runner helper with the real profile - // Use self.browser_runner instead of instance() BrowserRunner::instance() .get_browser_executable_path(profile) .map_err(|e| format!("Failed to get Camoufox executable path: {e}"))? - .to_string_lossy() - .to_string() }; - config_args.extend(["--executable-path".to_string(), executable_path]); - // Pass existing fingerprint if provided (for advanced form partial fingerprints) - if let Some(fingerprint) = &config.fingerprint { - config_args.extend(["--fingerprint".to_string(), fingerprint.clone()]); - } + // Build the config using CamoufoxConfigBuilder + let mut builder = CamoufoxConfigBuilder::new() + .block_images(config.block_images.unwrap_or(false)) + .block_webrtc(config.block_webrtc.unwrap_or(false)) + .block_webgl(config.block_webgl.unwrap_or(false)); - if let Some(serde_json::Value::Bool(true)) = &config.geoip { - config_args.push("--geoip".to_string()); - } - - // Add proxy if provided (can be passed directly during fingerprint generation) - if let Some(proxy) = &config.proxy { - config_args.extend(["--proxy".to_string(), proxy.clone()]); - } - - // Add screen dimensions if provided - if let Some(max_width) = config.screen_max_width { - config_args.extend(["--max-width".to_string(), max_width.to_string()]); - } - - if let Some(max_height) = config.screen_max_height { - config_args.extend(["--max-height".to_string(), max_height.to_string()]); - } - - if let Some(min_width) = config.screen_min_width { - config_args.extend(["--min-width".to_string(), min_width.to_string()]); - } - - if let Some(min_height) = config.screen_min_height { - config_args.extend(["--min-height".to_string(), min_height.to_string()]); - } - - // Add block_* options - if let Some(block_images) = config.block_images { - if block_images { - config_args.push("--block-images".to_string()); - } - } - - if let Some(block_webrtc) = config.block_webrtc { - if block_webrtc { - config_args.push("--block-webrtc".to_string()); - } - } - - if let Some(block_webgl) = config.block_webgl { - if block_webgl { - config_args.push("--block-webgl".to_string()); - } - } - - // Add OS option for fingerprint generation + // Set operating system if let Some(os) = &config.os { - config_args.extend(["--os".to_string(), os.clone()]); + builder = builder.operating_system(os); } - // Execute config generation command - let mut config_sidecar = self.get_nodecar_sidecar(app_handle)?; - for arg in &config_args { - config_sidecar = config_sidecar.arg(arg); + // Build screen constraints if provided + if config.screen_min_width.is_some() + || config.screen_max_width.is_some() + || config.screen_min_height.is_some() + || config.screen_max_height.is_some() + { + let screen_constraints = ScreenConstraints { + min_width: config.screen_min_width, + max_width: config.screen_max_width, + min_height: config.screen_min_height, + max_height: config.screen_max_height, + }; + builder = builder.screen_constraints(screen_constraints); } - let config_output = config_sidecar.output().await?; - if !config_output.status.success() { - let stderr = String::from_utf8_lossy(&config_output.stderr); - return Err(format!("Failed to generate camoufox fingerprint config: {stderr}").into()); + // Parse proxy if provided + if let Some(proxy_str) = &config.proxy { + let proxy_config = crate::camoufox::ProxyConfig::from_url(proxy_str) + .map_err(|e| format!("Failed to parse proxy URL: {e}"))?; + builder = builder.proxy(proxy_config); } - Ok(String::from_utf8_lossy(&config_output.stdout).to_string()) + // Set Firefox version from executable + if let Some(version) = crate::camoufox::config::get_firefox_version(&executable_path) { + builder = builder.ff_version(version); + } + + // Handle geoip option + if let Some(geoip_value) = &config.geoip { + match geoip_value { + serde_json::Value::Bool(true) => { + // Auto-detect IP (through proxy if set) + builder = builder.geoip(GeoIPOption::Auto); + } + serde_json::Value::String(ip) => { + // Use specific IP + builder = builder.geoip(GeoIPOption::IP(ip.clone())); + } + _ => { + // geoip: false or other values - don't apply geolocation + } + } + } + + // Build the config (async to handle geoip) + let launch_config = builder + .build_async() + .await + .map_err(|e| format!("Failed to build Camoufox config: {e}"))?; + + // Return the fingerprint config as JSON + let config_json = serde_json::to_string(&launch_config.fingerprint_config) + .map_err(|e| format!("Failed to serialize config: {e}"))?; + + Ok(config_json) } - /// Get the nodecar sidecar command - fn get_nodecar_sidecar( - &self, - app_handle: &AppHandle, - ) -> Result> { - let shell = app_handle.shell(); - let sidecar_command = shell - .sidecar("nodecar") - .map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?; - Ok(sidecar_command) - } - - /// Launch Camoufox browser using nodecar sidecar + /// Launch Camoufox browser by directly spawning the process pub async fn launch_camoufox( &self, - app_handle: &AppHandle, + _app_handle: &AppHandle, profile: &crate::profile::BrowserProfile, profile_path: &str, config: &CamoufoxConfig, @@ -221,44 +202,39 @@ impl CamoufoxManager { return Err("No fingerprint provided".into()); }; - // Always ensure executable_path is set to the user's binary location + // Get executable path let executable_path = if let Some(path) = &config.executable_path { - path.clone() + PathBuf::from(path) } else { - // Use the browser runner helper with the real profile - // Use self.browser_runner instead of instance() BrowserRunner::instance() .get_browser_executable_path(profile) .map_err(|e| format!("Failed to get Camoufox executable path: {e}"))? - .to_string_lossy() - .to_string() }; - // Build nodecar command arguments - let mut args = vec!["camoufox".to_string(), "start".to_string()]; + // Parse the fingerprint config JSON + let fingerprint_config: HashMap = + serde_json::from_str(&custom_config) + .map_err(|e| format!("Failed to parse fingerprint config: {e}"))?; - // Add profile path - ensure it's an absolute path - let absolute_profile_path = std::path::Path::new(profile_path) - .canonicalize() - .unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf()) - .to_string_lossy() - .to_string(); - args.extend(["--profile-path".to_string(), absolute_profile_path]); + // Convert to environment variables using CAMOU_CONFIG chunking + let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config) + .map_err(|e| format!("Failed to convert config to env vars: {e}"))?; + + // Build command arguments + let mut args = vec![ + "-profile".to_string(), + std::path::Path::new(profile_path) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf()) + .to_string_lossy() + .to_string(), + "-no-remote".to_string(), + ]; // Add URL if provided if let Some(url) = url { - args.extend(["--url".to_string(), url.to_string()]); - } - - // Always add the executable path - args.extend(["--executable-path".to_string(), executable_path]); - - // Always add the generated custom config - args.extend(["--custom-config".to_string(), custom_config]); - - // Add proxy if provided - if let Some(proxy) = &config.proxy { - args.extend(["--proxy".to_string(), proxy.clone()]); + args.push("-new-tab".to_string()); + args.push(url.to_string()); } // Add headless flag for tests @@ -266,43 +242,62 @@ impl CamoufoxManager { args.push("--headless".to_string()); } - // Get the nodecar sidecar command - let mut sidecar_command = self.get_nodecar_sidecar(app_handle)?; + log::info!( + "Launching Camoufox: {:?} with args: {:?}", + executable_path, + args + ); - // Add all arguments to the sidecar command - for arg in &args { - sidecar_command = sidecar_command.arg(arg); + // Spawn the browser process + let mut command = TokioCommand::new(&executable_path); + command + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + // Add environment variables + for (key, value) in &env_vars { + command.env(key, value); } - // Execute nodecar sidecar command - log::info!("Executing nodecar command with args: {args:?}"); - let output = sidecar_command.output().await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - log::info!("nodecar camoufox failed - stdout: {stdout}, stderr: {stderr}"); - return Err(format!("nodecar camoufox failed: {stderr}").into()); + // Handle fontconfig on Linux + if cfg!(target_os = "linux") { + let target_os = config.os.as_deref().unwrap_or("linux"); + if let Some(fontconfig_path) = + crate::camoufox::env_vars::get_fontconfig_env(target_os, &executable_path) + { + command.env("FONTCONFIG_PATH", fontconfig_path); + } } - let stdout = String::from_utf8_lossy(&output.stdout); - log::info!("nodecar camoufox output: {stdout}"); + let child = command + .spawn() + .map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?; - // Parse the JSON output - let launch_result: CamoufoxLaunchResult = serde_json::from_str(&stdout) - .map_err(|e| format!("Failed to parse nodecar output as JSON: {e}\nOutput was: {stdout}"))?; + let process_id = child.id(); + let instance_id = format!("camoufox_{}", process_id.unwrap_or(0)); + + log::info!("Camoufox launched with PID: {:?}", process_id); // Store the instance let instance = CamoufoxInstance { - id: launch_result.id.clone(), - process_id: launch_result.processId, - profile_path: launch_result.profilePath.clone(), - url: launch_result.url.clone(), + id: instance_id.clone(), + process_id, + profile_path: Some(profile_path.to_string()), + url: url.map(String::from), + }; + + let launch_result = CamoufoxLaunchResult { + id: instance_id.clone(), + processId: process_id, + profilePath: Some(profile_path.to_string()), + url: url.map(String::from), }; { let mut inner = self.inner.lock().await; - inner.instances.insert(launch_result.id.clone(), instance); + inner.instances.insert(instance_id, instance); } Ok(launch_result) @@ -311,41 +306,70 @@ impl CamoufoxManager { /// Stop a Camoufox process by ID pub async fn stop_camoufox( &self, - app_handle: &AppHandle, + _app_handle: &AppHandle, id: &str, ) -> Result> { - // Get the nodecar sidecar command - let sidecar_command = self - .get_nodecar_sidecar(app_handle)? - .arg("camoufox") - .arg("stop") - .arg("--id") - .arg(id); + // Get the process ID from our tracking + let process_id = { + let inner = self.inner.lock().await; + inner + .instances + .get(id) + .and_then(|instance| instance.process_id) + }; - // Execute nodecar stop command - let output = sidecar_command.output().await?; + if let Some(pid) = process_id { + // Kill the process + let success = self.kill_process(pid); - if !output.status.success() { - let _stderr = String::from_utf8_lossy(&output.stderr); - return Ok(false); - } + if success { + // Remove from our tracking + let mut inner = self.inner.lock().await; + inner.instances.remove(id); + log::info!("Stopped Camoufox instance {} (PID: {})", id, pid); + } - let stdout = String::from_utf8_lossy(&output.stdout); - let result: serde_json::Value = serde_json::from_str(&stdout) - .map_err(|e| format!("Failed to parse nodecar stop output: {e}"))?; - - let success = result - .get("success") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if success { - // Remove from our tracking + Ok(success) + } else { + // No process ID found, just remove from tracking let mut inner = self.inner.lock().await; inner.instances.remove(id); + Ok(true) + } + } + + /// Kill a process by PID + fn kill_process(&self, pid: u32) -> bool { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + let result = std::process::Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status(); + + match result { + Ok(status) => status.success() || status.signal() == Some(0), + Err(e) => { + log::warn!("Failed to kill process {}: {}", pid, e); + false + } + } } - Ok(success) + #[cfg(windows)] + { + let result = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T"]) + .status(); + + match result { + Ok(status) => status.success(), + Err(e) => { + log::warn!("Failed to kill process {}: {}", pid, e); + false + } + } + } } /// Find Camoufox server by profile path (for integration with browser_runner) @@ -544,7 +568,7 @@ impl CamoufoxManager { } impl CamoufoxManager { - pub async fn launch_camoufox_profile_nodecar( + pub async fn launch_camoufox_profile( &self, app_handle: AppHandle, profile: BrowserProfile, @@ -574,7 +598,7 @@ impl CamoufoxManager { url.as_deref(), ) .await - .map_err(|e| format!("Failed to launch Camoufox via nodecar: {e}")) + .map_err(|e| format!("Failed to launch Camoufox: {e}")) } } @@ -597,5 +621,5 @@ mod tests { // Global singleton instance lazy_static::lazy_static! { - static ref CAMOUFOX_NODECAR_LAUNCHER: CamoufoxManager = CamoufoxManager::new(); + static ref CAMOUFOX_LAUNCHER: CamoufoxManager = CamoufoxManager::new(); } diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index aafc559..1ccce3e 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -350,6 +350,50 @@ impl Downloader { } } + /// Ensure version.json exists in the Camoufox installation directory. + /// Creates the file if it doesn't exist, using the version from the tag name. + async fn ensure_camoufox_version_json( + &self, + browser_dir: &Path, + version: &str, + ) -> Result<(), Box> { + // The browser_dir is typically: binaries/camoufox// + // Find the executable directory within it + let version_json_locations = vec![ + browser_dir.join("version.json"), + browser_dir.join("camoufox").join("version.json"), + ]; + + // Check if version.json already exists in any expected location + for location in &version_json_locations { + if location.exists() { + log::info!("version.json already exists at: {}", location.display()); + return Ok(()); + } + } + + // Parse the Firefox version from the Camoufox version tag + // Format: "135.0.1-beta.24" -> Firefox version is "135.0.1" (or just "135.0") + let firefox_version = version.split('-').next().unwrap_or(version); + + // Create version.json in the browser directory + let version_json_path = browser_dir.join("version.json"); + let version_data = serde_json::json!({ + "version": firefox_version + }); + + let version_json_str = serde_json::to_string_pretty(&version_data)?; + tokio::fs::write(&version_json_path, version_json_str).await?; + + log::info!( + "Created version.json at {} with Firefox version: {}", + version_json_path.display(), + firefox_version + ); + + Ok(()) + } + pub async fn download_browser( &self, app_handle: &tauri::AppHandle, @@ -809,7 +853,7 @@ impl Downloader { } } - // If this is Camoufox, automatically download GeoIP database + // If this is Camoufox, automatically download GeoIP database and create version.json if browser_str == "camoufox" { // Check if GeoIP database is already available if !crate::geoip_downloader::GeoIPDownloader::is_geoip_database_available() { @@ -831,6 +875,15 @@ impl Downloader { } else { log::info!("GeoIP database already available"); } + + // Create version.json if it doesn't exist + if let Err(e) = self + .ensure_camoufox_version_json(&browser_dir, &version) + .await + { + log::warn!("Failed to create version.json for Camoufox: {e}"); + // Don't fail the download if version.json creation fails + } } // Emit completion diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b96e1f2..f75f19e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ mod auto_updater; mod browser; mod browser_runner; mod browser_version_manager; +pub mod camoufox; mod camoufox_manager; mod default_browser; mod downloaded_browsers_registry; @@ -143,35 +144,6 @@ impl WindowExt for WebviewWindow { } } -#[tauri::command] -async fn warm_up_nodecar(app: tauri::AppHandle) -> Result<(), String> { - use tauri_plugin_shell::ShellExt; - use tokio::time::{timeout, Duration}; - - let start_time = std::time::Instant::now(); - - // Use sidecar to execute a fast, harmless command that ensures the binary is loaded - let cmd = app - .shell() - .sidecar("nodecar") - .map_err(|e| format!("Failed to create nodecar sidecar: {e}"))? - .arg("help"); - - let exec_future = async { cmd.output().await }; - match timeout(Duration::from_secs(120), exec_future).await { - Ok(Ok(_output)) => { - let duration = start_time.elapsed(); - log::info!( - "Nodecar warm-up (frontend-triggered) completed in {:.2}s", - duration.as_secs_f64() - ); - Ok(()) - } - Ok(Err(e)) => Err(format!("Failed to execute nodecar for warm-up: {e}")), - Err(_) => Err("Nodecar warm-up timed out after 120s".to_string()), - } -} - #[tauri::command] async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> { log::info!("handle_url_open called with URL: {url}"); @@ -838,7 +810,6 @@ pub fn run() { delete_selected_profiles, is_geoip_database_available, download_geoip_database, - warm_up_nodecar, start_api_server, stop_api_server, get_api_server_status, diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 074b3c8..623c916 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -820,11 +820,9 @@ impl ProfileManager { app_handle: tauri::AppHandle, profile: &BrowserProfile, ) -> Result> { - // Handle Camoufox profiles using nodecar-based status checking + // Handle Camoufox profiles using CamoufoxManager-based status checking if profile.browser == "camoufox" { - return self - .check_camoufox_status_via_nodecar(&app_handle, profile) - .await; + return self.check_camoufox_status(&app_handle, profile).await; } // For non-camoufox browsers, use the existing PID-based logic @@ -888,7 +886,7 @@ impl ProfileManager { "zen" => exe_name.contains("zen"), "chromium" => exe_name.contains("chromium"), "brave" => exe_name.contains("brave"), - // Camoufox is handled via nodecar, not PID-based checking + // Camoufox is handled via CamoufoxManager, not PID-based checking _ => false, }; @@ -981,8 +979,8 @@ impl ProfileManager { Ok(is_running) } - // Check Camoufox status using nodecar-based approach - async fn check_camoufox_status_via_nodecar( + // Check Camoufox status using CamoufoxManager + async fn check_camoufox_status( &self, app_handle: &tauri::AppHandle, profile: &BrowserProfile, @@ -1062,7 +1060,7 @@ impl ProfileManager { } Err(e) => { // Error checking status, assume not running and clear process ID - log::warn!("Warning: Failed to check Camoufox status via nodecar: {e}"); + log::warn!("Warning: Failed to check Camoufox status: {e}"); let profiles_dir = self.get_profiles_dir(); let profile_uuid_dir = profiles_dir.join(profile.id.to_string()); let metadata_file = profile_uuid_dir.join("metadata.json"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d89c0ce..1a4e187 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "identifier": "com.donutbrowser", "build": { "beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev", - "devUrl": "http://localhost:3000", + "devUrl": "http://localhost:12341", "beforeBuildCommand": "pnpm copy-proxy-binary && (test -d ../dist || pnpm build)", "frontendDist": "../dist" }, @@ -19,7 +19,7 @@ "active": true, "targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"], "category": "Productivity", - "externalBin": ["binaries/nodecar", "binaries/donut-proxy"], + "externalBin": ["binaries/donut-proxy"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src-tauri/tests/sync_e2e.rs b/src-tauri/tests/sync_e2e.rs index d2b3c7a..7c3b16d 100644 --- a/src-tauri/tests/sync_e2e.rs +++ b/src-tauri/tests/sync_e2e.rs @@ -9,7 +9,7 @@ use tempfile::TempDir; const TEST_TOKEN: &str = "test-sync-token"; fn get_sync_server_url() -> String { - env::var("SYNC_SERVER_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()) + env::var("SYNC_SERVER_URL").unwrap_or_else(|_| "http://localhost:12342".to_string()) } /// Check if sync server is available and fail with a clear error message if not. diff --git a/src/app/page.tsx b/src/app/page.tsx index ab6c3be..2ab034d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -47,7 +47,6 @@ interface PendingUrl { export default function Home() { // Mount global version update listener/toasts useVersionUpdater(); - const [isInitializing, setIsInitializing] = useState(true); // Use the new profile events hook for centralized profile management const { @@ -257,27 +256,6 @@ export default function Home() { } }, [hasCheckedStartupPrompt]); - // Warm up nodecar at startup and block UI until complete - useEffect(() => { - let cancelled = false; - (async () => { - try { - await invoke("warm_up_nodecar"); - } catch (err) { - if (!cancelled) { - // Don't set error here since useProfileEvents handles profile errors - console.error("Initialization failed:", err); - } - } finally { - if (!cancelled) setIsInitializing(false); - } - })(); - - return () => { - cancelled = true; - }; - }, []); - // Handle profile errors from useProfileEvents hook useEffect(() => { if (profilesError) { @@ -795,18 +773,6 @@ export default function Home() { - {isInitializing && ( -
-
-
Initializing
-
- Please don't close the app -
-
-
-
- )} - { diff --git a/tsconfig.json b/tsconfig.json index 477dac1..b16271c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,5 +33,11 @@ ".next/dev/types/**/*.ts", "dist/dev/types/**/*.ts" ], - "exclude": ["node_modules", "nodecar", "src-tauri/target", "donut-sync"] + "exclude": [ + "node_modules", + "nodecar", + "src-tauri/target", + "donut-sync", + "camoufox-js" + ] }