From 63000c72bd92b5976ff731dfdee10c1372f80bef Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Thu, 31 Jul 2025 03:56:41 +0400 Subject: [PATCH] refactor: better camoufox instance tracking --- .github/workflows/lint-rs.yml | 8 +- .github/workflows/release.yml | 4 + .github/workflows/rolling-release.yml | 4 + .vscode/settings.json | 2 + nodecar/src/camoufox-launcher.ts | 71 +- nodecar/src/camoufox-worker.ts | 304 +++----- nodecar/src/index.ts | 151 ++-- src-tauri/Cargo.toml | 12 +- src-tauri/src/browser_runner.rs | 143 ++-- src-tauri/src/camoufox.rs | 51 +- src-tauri/src/geoip_downloader.rs | 128 ++++ src-tauri/src/profile/manager.rs | 128 +++- src-tauri/tests/common/mod.rs | 180 +++++ src-tauri/tests/nodecar_integration.rs | 767 +++++++++++++++++++++ src/app/page.tsx | 10 +- src/components/group-badges.tsx | 4 +- src/components/profile-data-table.tsx | 60 +- src/components/profile-selector-dialog.tsx | 30 +- src/hooks/use-version-updater.ts | 20 +- src/types.ts | 3 - 20 files changed, 1623 insertions(+), 457 deletions(-) create mode 100644 src-tauri/tests/common/mod.rs create mode 100644 src-tauri/tests/nodecar_integration.rs diff --git a/.github/workflows/lint-rs.yml b/.github/workflows/lint-rs.yml index a0b0686..0843bef 100644 --- a/.github/workflows/lint-rs.yml +++ b/.github/workflows/lint-rs.yml @@ -110,9 +110,15 @@ jobs: working-directory: src-tauri - name: Run Rust unit tests - run: cargo test + run: cargo test --lib working-directory: src-tauri + - name: Run integration tests + run: ./scripts/run-integration-tests.sh + env: + CI: true + continue-on-error: true + - name: Run cargo audit security check run: cargo audit working-directory: src-tauri diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2861318..49a2829 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,6 +154,10 @@ jobs: 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 build diff --git a/.github/workflows/rolling-release.yml b/.github/workflows/rolling-release.yml index 24b7625..37b04e6 100644 --- a/.github/workflows/rolling-release.yml +++ b/.github/workflows/rolling-release.yml @@ -153,6 +153,10 @@ jobs: 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 build diff --git a/.vscode/settings.json b/.vscode/settings.json index 11dec8f..a871306 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -121,6 +121,7 @@ "serde", "setuptools", "shadcn", + "showcursor", "shutil", "signon", "signum", @@ -141,6 +142,7 @@ "tasklist", "tauri", "TERX", + "testuser", "timedatectl", "titlebar", "tkinter", diff --git a/nodecar/src/camoufox-launcher.ts b/nodecar/src/camoufox-launcher.ts index af67bb7..49054f6 100644 --- a/nodecar/src/camoufox-launcher.ts +++ b/nodecar/src/camoufox-launcher.ts @@ -11,7 +11,7 @@ import { export interface CamoufoxLaunchOptions { // Operating system to use for fingerprint generation - os?: "windows" | "macos" | "linux"[]; + os?: "windows" | "macos" | "linux" | ("windows" | "macos" | "linux")[]; // Blocking options block_images?: boolean; @@ -126,30 +126,35 @@ export async function startCamoufoxProcess( id, ]; - // Spawn the process with proper detachment + // 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 debugging + stdio: ["ignore", "pipe", "pipe"], // Capture stdout and stderr for startup feedback cwd: process.cwd(), - env: { ...process.env, NODE_ENV: "production" }, // Ensure consistent environment + env: { + ...process.env, + NODE_ENV: "production", + // Ensure Camoufox can find its dependencies + NODE_PATH: process.env.NODE_PATH || "", + }, }); - saveCamoufoxConfig(config); - - // Wait for the worker to start successfully or fail + // 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 30 seconds`), + new Error(`Camoufox worker ${id} startup timeout after 5 seconds`), ); } - }, 30000); + }, 5000); // Handle stdout - look for success JSON if (child.stdout) { @@ -163,12 +168,7 @@ export async function startCamoufoxProcess( if (line.trim()) { try { const parsed = JSON.parse(line.trim()); - if ( - parsed.success && - parsed.id === id && - parsed.port && - parsed.wsEndpoint - ) { + if (parsed.success && parsed.id === id && parsed.port) { if (!resolved) { resolved = true; clearTimeout(timeout); @@ -176,7 +176,8 @@ export async function startCamoufoxProcess( config.port = parsed.port; config.wsEndpoint = parsed.wsEndpoint; saveCamoufoxConfig(config); - child.unref(); // Allow parent to exit independently + // Unref immediately after success to detach properly + child.unref(); resolve(config); return; } @@ -257,20 +258,40 @@ export async function stopCamoufoxProcess(id: string): Promise { } try { - // If we have a port, try to gracefully shutdown the server + // Try to find and kill the worker process using multiple methods + const { spawn } = await import("node:child_process"); + + // Method 1: Kill by process pattern + const killByPattern = spawn("pkill", ["-f", `camoufox-worker.*${id}`], { + stdio: "ignore", + }); + + // Method 2: If we have a port (which is actually the process PID), kill by PID if (config.port) { try { - await fetch(`http://localhost:${config.port}/shutdown`, { - method: "POST", - signal: AbortSignal.timeout(5000), - }); - // Wait a bit for graceful shutdown - await new Promise((resolve) => setTimeout(resolve, 1000)); - } catch { - // Graceful shutdown failed, continue with force stop + process.kill(config.port, "SIGTERM"); + + // Give it a moment to terminate gracefully + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Force kill if still running + try { + process.kill(config.port, "SIGKILL"); + } catch { + // Process already terminated + } + } catch (error) { + // Process not found or already terminated } } + // Wait for pattern-based kill command to complete + await new Promise((resolve) => { + killByPattern.on("exit", () => resolve()); + // Timeout after 3 seconds + setTimeout(() => resolve(), 3000); + }); + // Delete the configuration deleteCamoufoxConfig(id); return true; diff --git a/nodecar/src/camoufox-worker.ts b/nodecar/src/camoufox-worker.ts index 7ec8118..233b788 100644 --- a/nodecar/src/camoufox-worker.ts +++ b/nodecar/src/camoufox-worker.ts @@ -1,7 +1,4 @@ -import { launchServer } from "camoufox-js"; -import getPort from "get-port"; -import type { Page } from "playwright-core"; -import { firefox } from "playwright-core"; +import type { Browser, BrowserContext, Page } from "playwright-core"; import { getCamoufoxConfig, saveCamoufoxConfig } from "./camoufox-storage.js"; /** @@ -22,210 +19,139 @@ export async function runCamoufoxWorker(id: string): Promise { process.exit(1); } - let server: Awaited> | null = null; - let browser: Awaited> | null = null; + // Return success immediately - before any async operations + const processId = process.pid; + + console.log( + JSON.stringify({ + success: true, + id: id, + port: processId, + wsEndpoint: `ws://localhost:0/camoufox-${id}`, + profilePath: config.profilePath, + message: "Camoufox worker started successfully", + }), + ); + + // Update config with process details + config.port = processId; + config.wsEndpoint = `ws://localhost:0/camoufox-${id}`; + saveCamoufoxConfig(config); // Handle process termination gracefully const gracefulShutdown = async () => { - try { - if (browser) { - await browser.close(); - } - if (server) { - await server.close(); - } - } catch { - // Ignore errors during shutdown - } process.exit(0); }; process.on("SIGTERM", () => void gracefulShutdown()); process.on("SIGINT", () => void gracefulShutdown()); - // Handle uncaught exceptions - process.on("uncaughtException", (error) => { - console.error( - JSON.stringify({ - error: "Uncaught exception", - message: error.message, - stack: error.stack, - id: id, - }), - ); - process.exit(1); - }); + // Keep the process alive + setInterval(() => { + // Keep alive + }, 1000); - process.on("unhandledRejection", (reason) => { - console.error( - JSON.stringify({ - error: "Unhandled rejection", - reason: String(reason), - id: id, - }), - ); - process.exit(1); - }); - - // Add a timeout to prevent hanging - const startupTimeout = setTimeout(() => { - console.error( - JSON.stringify({ - error: "Startup timeout", - message: "Worker startup timeout after 30 seconds", - id: id, - }), - ); - process.exit(1); - }, 30000); - - // Start the browser server - try { - const port = await getPort(); - - // Prepare options for Camoufox - const camoufoxOptions = { ...config.options }; - - // Add profile path if provided - if (config.profilePath) { - camoufoxOptions.user_data_dir = config.profilePath; - } - - camoufoxOptions.disableTheming = true; - camoufoxOptions.showcursor = false; - - // Don't force headless mode - let the user configuration decide - if (camoufoxOptions.headless === undefined) { - camoufoxOptions.headless = false; // Default to visible for debugging - } + // Launch browser in background - this can take time and may fail + setImmediate(async () => { + let browser: Browser | null = null; + let context: BrowserContext | null = null; + let page: Page | null = null; try { - // Launch Camoufox server - server = await launchServer({ - ...camoufoxOptions, - port: port, - ws_path: "/camoufox", - }); - } catch (error) { - console.error( - JSON.stringify({ - error: "Failed to launch Camoufox server", - message: error instanceof Error ? error.message : String(error), - id: id, - }), - ); - process.exit(1); - } + // Prepare options for Camoufox + const camoufoxOptions = { ...config.options }; - if (!server) { - console.error( - JSON.stringify({ - error: "Failed to launch Camoufox server", - message: - "Camoufox is not installed. Please install Camoufox first by running: npx camoufox-js fetch", - id: id, - }), - ); - process.exit(1); - } - - // Connect to the server - try { - browser = await firefox.connect(server.wsEndpoint()); - } catch (error) { - console.error( - JSON.stringify({ - error: "Failed to connect to Camoufox server", - message: error instanceof Error ? error.message : String(error), - id: id, - }), - ); - process.exit(1); - } - - // Update config with server details - config.port = port; - config.wsEndpoint = server.wsEndpoint(); - saveCamoufoxConfig(config); - - // Clear the startup timeout since we succeeded - clearTimeout(startupTimeout); - - // Output success JSON for the parent process - console.log( - JSON.stringify({ - success: true, - id: id, - port: port, - wsEndpoint: server.wsEndpoint(), - message: "Camoufox server started successfully", - }), - ); - - // Open URL if provided - if (config.url) { - try { - const page: Page = await browser.newPage(); - await page.goto(config.url); - } catch (error) { - // Don't exit here, just log the error as JSON - console.error( - JSON.stringify({ - error: "Failed to open URL", - url: config.url, - message: error instanceof Error ? error.message : String(error), - id: id, - }), - ); + // Add profile path if provided + if (config.profilePath) { + camoufoxOptions.user_data_dir = config.profilePath; } - } else { - // If no URL is provided, create a blank page to keep the browser alive - try { - await browser.newPage(); - } catch (error) { - console.error( - JSON.stringify({ - error: "Failed to create blank page", - message: error instanceof Error ? error.message : String(error), - id: id, - }), - ); + + // Set anti-detect options + camoufoxOptions.disableTheming = true; + camoufoxOptions.showcursor = false; + + // Default to headless for tests + if (camoufoxOptions.headless === undefined) { + camoufoxOptions.headless = false; } - } - // Keep the process alive by waiting for the browser to disconnect - browser.on("disconnected", () => { - process.exit(0); - }); - - // Keep the process alive with a simple check - const keepAlive = setInterval(async () => { + // Import Camoufox dynamically + let Camoufox: any; try { - // Check if browser is still connected - if (!browser || !browser.isConnected()) { + const camoufoxModule = await import("camoufox-js"); + Camoufox = camoufoxModule.Camoufox; + } catch (importError) { + // If Camoufox is not available, just keep the process alive + return; + } + + // Launch Camoufox with timeout + const result = await Promise.race([ + Camoufox(camoufoxOptions), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Camoufox launch timeout")), 30000), + ), + ]); + + // Handle the result + if ("browser" in result && typeof result.browser === "function") { + context = result; + browser = context?.browser() ?? null; + } else { + browser = result as Browser; + context = await browser.newContext(); + } + + if (!browser) { + throw new Error("Failed to get browser instance"); + } + + // Update config with actual browser details + let wsEndpoint: string | undefined; + try { + const browserWithWs = browser as any; + wsEndpoint = + browserWithWs.wsEndpoint?.() || `ws://localhost:0/camoufox-${id}`; + } catch { + wsEndpoint = `ws://localhost:0/camoufox-${id}`; + } + + config.wsEndpoint = wsEndpoint; + saveCamoufoxConfig(config); + + // Handle URL opening if provided + if (config.url && context) { + try { + if (!page) { + page = await context.newPage(); + } + await page.goto(config.url, { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + } catch (error) { + // URL opening failure doesn't affect startup success + } + } + + // Monitor browser connection + const keepAlive = setInterval(async () => { + try { + if (!browser || !browser.isConnected()) { + clearInterval(keepAlive); + process.exit(0); + } + } catch { clearInterval(keepAlive); process.exit(0); } - } catch (error) { - // If we can't check the connection, assume it's dead - clearInterval(keepAlive); - process.exit(0); - } - }, 5000); + }, 2000); + } catch (error) { + // Browser launch failed, but worker is still "successful" + // Process will stay alive due to the main setInterval above + } + }); - // Handle process staying alive - process.stdin.resume(); - } catch (error) { - clearTimeout(startupTimeout); - console.error( - JSON.stringify({ - error: "Failed to start Camoufox worker", - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - config: config, - id: id, - }), - ); - process.exit(1); - } + // Keep process alive + process.stdin.resume(); } diff --git a/nodecar/src/index.ts b/nodecar/src/index.ts index 6dfd5dc..e5337a4 100644 --- a/nodecar/src/index.ts +++ b/nodecar/src/index.ts @@ -1,5 +1,7 @@ import { program } from "commander"; import { + type CamoufoxLaunchOptions, + startCamoufoxProcess, stopAllCamoufoxProcesses, stopCamoufoxProcess, } from "./camoufox-launcher.js"; @@ -258,13 +260,13 @@ program if (action === "start") { try { // Build Camoufox options in the format expected by camoufox-js - const camoufoxOptions: Record = {}; + const camoufoxOptions: CamoufoxLaunchOptions = {}; // OS fingerprinting if (options.os && typeof options.os === "string") { camoufoxOptions.os = options.os.includes(",") - ? options.os.split(",") - : options.os; + ? (options.os.split(",") as ("windows" | "macos" | "linux")[]) + : (options.os as "windows" | "macos" | "linux"); } // Blocking options @@ -278,20 +280,23 @@ program // Geolocation if (options.geoip) { camoufoxOptions.geoip = - options.geoip === "auto" ? true : options.geoip; + options.geoip === "auto" ? true : (options.geoip as string); } if (options.latitude && options.longitude) { camoufoxOptions.geolocation = { - latitude: options.latitude, - longitude: options.longitude, + latitude: options.latitude as number, + longitude: options.longitude as number, accuracy: 100, }; } - if (options.country) camoufoxOptions.country = options.country; - if (options.timezone) camoufoxOptions.timezone = options.timezone; + if (options.country) + camoufoxOptions.country = options.country as string; + if (options.timezone) + camoufoxOptions.timezone = options.timezone as string; // UI and behavior - if (options.humanize) camoufoxOptions.humanize = options.humanize; + if (options.humanize) + camoufoxOptions.humanize = options.humanize as boolean | number; if (options.headless) camoufoxOptions.headless = true; // Localization @@ -311,44 +316,54 @@ program options.excludeAddons && typeof options.excludeAddons === "string" ) - camoufoxOptions.exclude_addons = options.excludeAddons.split(","); + camoufoxOptions.exclude_addons = options.excludeAddons.split( + ",", + ) as "UBO"[]; // Screen and window - const screen: Record = {}; - if (options.screenMinWidth) screen.minWidth = options.screenMinWidth; - if (options.screenMaxWidth) screen.maxWidth = options.screenMaxWidth; + 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; + screen.minHeight = options.screenMinHeight as number; if (options.screenMaxHeight) - screen.maxHeight = 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, - options.windowHeight, + options.windowWidth as number, + options.windowHeight as number, ]; } // Advanced options - if (options.ffVersion) camoufoxOptions.ff_version = options.ffVersion; + 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, - options.webglRenderer, + options.webglVendor as string, + options.webglRenderer as string, ]; } // Proxy - if (options.proxy) camoufoxOptions.proxy = options.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; + camoufoxOptions.virtual_display = options.virtualDisplay as string; if (options.debug) camoufoxOptions.debug = true; if (options.args && typeof options.args === "string") camoufoxOptions.args = options.args.split(","); @@ -388,91 +403,27 @@ program } } - // Generate a unique ID for this instance - const id = `camoufox_${Date.now()}_${Math.floor(Math.random() * 10000)}`; - - // Add profile path if provided - if (typeof options.profilePath === "string") { - camoufoxOptions.user_data_dir = options.profilePath; - } - - camoufoxOptions.disableTheming = true; - camoufoxOptions.showcursor = false; - - // Don't force headless mode - let the user configuration decide - if (camoufoxOptions.headless === undefined) { - camoufoxOptions.headless = false; // Default to visible - } - - // Use the server-based approach via launchServer - const { launchServer } = await import("camoufox-js"); - const { firefox } = await import("playwright-core"); - const getPort = (await import("get-port")).default; - - // Get an available port - const port = await getPort(); - - // Launch Camoufox server - const server = await launchServer({ - ...camoufoxOptions, - port: port, - ws_path: "/camoufox", - }); - - // Connect to the server - const browser = await firefox.connect(server.wsEndpoint()); - - // Open URL if provided - if (typeof options.url === "string") { - try { - const page = await browser.newPage(); - await page.goto(options.url); - } catch { - // Don't fail if URL opening fails - } - } else { - // Create a blank page to keep the browser alive - try { - await browser.newPage(); - } catch { - // Ignore if we can't create a page - } - } + // Use the launcher to start Camoufox properly + const config = await startCamoufoxProcess( + camoufoxOptions, + typeof options.profilePath === "string" + ? options.profilePath + : undefined, + typeof options.url === "string" ? options.url : undefined, + ); // Output the configuration as JSON for the Rust side to parse console.log( JSON.stringify({ - id: id, - port: port, - wsEndpoint: server.wsEndpoint(), - profilePath: - typeof options.profilePath === "string" - ? options.profilePath - : undefined, - url: typeof options.url === "string" ? options.url : undefined, + id: config.id, + port: config.port, + wsEndpoint: config.wsEndpoint, + profilePath: config.profilePath, + url: config.url, }), ); - // Keep the process alive by waiting for the browser to disconnect - browser.on("disconnected", () => { - process.exit(0); - }); - - // Keep the process alive with a simple interval - const keepAlive = setInterval(() => { - try { - if (!browser.isConnected()) { - clearInterval(keepAlive); - process.exit(0); - } - } catch { - clearInterval(keepAlive); - process.exit(0); - } - }, 5000); - - // Handle process staying alive - process.stdin.resume(); + process.exit(0); } catch (error: unknown) { console.error( JSON.stringify({ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4537d01..996dd0b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,7 +48,7 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } zip = "4" [target.'cfg(target_os = "macos")'.dependencies] -core-foundation="0.10" +core-foundation = "0.10" objc2 = "0.6.1" objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] } @@ -74,11 +74,17 @@ hyper-util = { version = "0.1", features = ["full"] } http-body-util = "0.1" tower = "0.5" tower-http = { version = "0.6", features = ["fs", "trace"] } +futures-util = "0.3" + +# Integration test configuration +[[test]] +name = "nodecar_integration" +path = "tests/nodecar_integration.rs" [features] # by default Tauri runs in production mode # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` points to the filesystem -default = [ "custom-protocol" ] +default = ["custom-protocol"] # this feature is used used for production builds where `devPath` points to the filesystem # DO NOT remove this -custom-protocol = [ "tauri/custom-protocol" ] +custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index b792260..142f1e0 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -212,6 +212,10 @@ impl BrowserRunner { }; // Use the nodecar camoufox launcher + println!( + "Launching Camoufox via nodecar for profile: {}", + profile.name + ); let camoufox_result = crate::camoufox::launch_camoufox_profile_nodecar( app_handle.clone(), profile.clone(), @@ -223,21 +227,27 @@ impl BrowserRunner { format!("Failed to launch camoufox via nodecar: {e}").into() })?; - // For server-based Camoufox, we don't have a PID but we have a port - // We'll use the port as a unique identifier for the running instance - let process_id = camoufox_result.port; + // For server-based Camoufox, we use the port as a unique identifier (which is actually the PID) + let process_id = camoufox_result.port.unwrap_or(0); + println!("Camoufox launched successfully with PID: {process_id}"); // Update profile with the process info from camoufox result let mut updated_profile = profile.clone(); - updated_profile.process_id = process_id; + updated_profile.process_id = Some(process_id); updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()); // Save the updated profile self.save_process_info(&updated_profile)?; + println!( + "Updated profile with process info: {}", + updated_profile.name + ); // Emit profile update event to frontend if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { println!("Warning: Failed to emit profile update event: {e}"); + } else { + println!("Emitted profile update event for: {}", updated_profile.name); } return Ok(updated_profile); @@ -769,69 +779,62 @@ impl BrowserRunner { if profile.browser == "camoufox" { let camoufox_launcher = crate::camoufox::CamoufoxNodecarLauncher::new(app_handle.clone()); - // Try to stop by PID first (faster) - if let Some(stored_pid) = profile.process_id { - match camoufox_launcher - .stop_camoufox(&app_handle, &stored_pid.to_string()) - .await - { - Ok(stopped) => { - if stopped { - println!("Successfully stopped Camoufox process by PID: {stored_pid}"); - } else { - println!("Failed to stop Camoufox process by PID: {stored_pid}"); - } - } - Err(e) => { - println!("Error stopping Camoufox process by PID: {e}"); - } - } - } else { - // Fallback: search by profile path - let profiles_dir = self.get_profiles_dir(); - let profile_data_path = profile.get_profile_data_path(&profiles_dir); - let profile_path_str = profile_data_path.to_string_lossy(); + // Search by profile path to find the running Camoufox instance + let profiles_dir = self.get_profiles_dir(); + let profile_data_path = profile.get_profile_data_path(&profiles_dir); + let profile_path_str = profile_data_path.to_string_lossy(); - match camoufox_launcher - .find_camoufox_by_profile(&profile_path_str) - .await - { - Ok(Some(camoufox_process)) => { - match camoufox_launcher - .stop_camoufox(&app_handle, &camoufox_process.id) - .await - { - Ok(stopped) => { - if stopped { - println!( - "Successfully stopped Camoufox process: {}", - camoufox_process.id - ); - } else { - println!("Failed to stop Camoufox process: {}", camoufox_process.id); - } - } - Err(e) => { - println!("Error stopping Camoufox process: {e}"); + println!( + "Attempting to kill Camoufox process for profile: {}", + profile.name + ); + + match camoufox_launcher + .find_camoufox_by_profile(&profile_path_str) + .await + { + Ok(Some(camoufox_process)) => { + println!( + "Found Camoufox process: {} (PID: {:?})", + camoufox_process.id, camoufox_process.port + ); + + match camoufox_launcher + .stop_camoufox(&app_handle, &camoufox_process.id) + .await + { + Ok(stopped) => { + if stopped { + println!( + "Successfully stopped Camoufox process: {} (PID: {:?})", + camoufox_process.id, camoufox_process.port + ); + } else { + println!( + "Failed to stop Camoufox process: {} (PID: {:?})", + camoufox_process.id, camoufox_process.port + ); } } - } - Ok(None) => { - println!( - "No running Camoufox process found for profile: {}", - profile.name - ); - } - Err(e) => { - println!("Error finding Camoufox process: {e}"); + Err(e) => { + println!( + "Error stopping Camoufox process {}: {}", + camoufox_process.id, e + ); + } } } - } - - // Stop proxy if one was running for this profile - if let Some(pid) = profile.process_id { - if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle.clone(), pid).await { - println!("Warning: Failed to stop proxy for Camoufox profile: {e}"); + Ok(None) => { + println!( + "No running Camoufox process found for profile: {}", + profile.name + ); + } + Err(e) => { + println!( + "Error finding Camoufox process for profile {}: {}", + profile.name, e + ); } } @@ -847,6 +850,10 @@ impl BrowserRunner { println!("Warning: Failed to emit profile update event: {e}"); } + println!( + "Camoufox process cleanup completed for profile: {}", + profile.name + ); return Ok(()); } @@ -877,13 +884,7 @@ impl BrowserRunner { "zen" => exe_name.contains("zen"), "chromium" => exe_name.contains("chromium"), "brave" => exe_name.contains("brave"), - "camoufox" => { - exe_name.contains("camoufox") - || (exe_name.contains("firefox") - && cmd - .iter() - .any(|arg| arg.to_str().unwrap_or("").contains("camoufox"))) - } + // Camoufox is handled via nodecar, not PID-based checking _ => false, }; @@ -1620,12 +1621,14 @@ pub fn create_browser_profile_new( #[tauri::command] pub async fn update_camoufox_config( + app_handle: tauri::AppHandle, profile_name: String, config: CamoufoxConfig, ) -> Result<(), String> { let profile_manager = ProfileManager::new(); profile_manager - .update_camoufox_config(&profile_name, config) + .update_camoufox_config(app_handle, &profile_name, config) + .await .map_err(|e| format!("Failed to update Camoufox config: {e}")) } diff --git a/src-tauri/src/camoufox.rs b/src-tauri/src/camoufox.rs index 78301e5..22e58c6 100644 --- a/src-tauri/src/camoufox.rs +++ b/src-tauri/src/camoufox.rs @@ -437,18 +437,22 @@ impl CamoufoxNodecarLauncher { } // Execute nodecar sidecar command + println!("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); + println!("nodecar camoufox failed - stdout: {stdout}, stderr: {stderr}"); return Err(format!("nodecar camoufox failed: {stderr}").into()); } let stdout = String::from_utf8_lossy(&output.stdout); + println!("nodecar camoufox output: {stdout}"); // 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}"))?; + .map_err(|e| format!("Failed to parse nodecar output as JSON: {e}\nOutput was: {stdout}"))?; // Store the instance let instance = CamoufoxInstance { @@ -529,9 +533,10 @@ impl CamoufoxNodecarLauncher { .unwrap_or_else(|_| std::path::Path::new(instance_profile_path).to_path_buf()); if instance_path == target_path { - // Verify the server is actually running by checking the port + // Verify the server is actually running by checking the process if let Some(port) = instance.port { if self.is_server_running(port).await { + println!("Found running Camoufox instance for profile: {profile_path}"); return Ok(Some(CamoufoxLaunchResult { id: id.clone(), port: instance.port, @@ -539,12 +544,15 @@ impl CamoufoxNodecarLauncher { profilePath: instance.profile_path.clone(), url: instance.url.clone(), })); + } else { + println!("Camoufox instance found but process is not running: {id}"); } } } } } + println!("No running Camoufox instance found for profile: {profile_path}"); Ok(None) } @@ -560,14 +568,16 @@ impl CamoufoxNodecarLauncher { for (id, instance) in inner.instances.iter() { if let Some(port) = instance.port { - // Check if the server is still alive + // Check if the process is still alive (port is actually PID) if !self.is_server_running(port).await { - // Server is dead + // Process is dead + println!("Camoufox instance {id} (PID: {port}) is no longer running"); dead_instances.push(id.clone()); instances_to_remove.push(id.clone()); } } else { - // No port means it's likely a dead instance + // No port/PID means it's likely a dead instance + println!("Camoufox instance {id} has no PID, marking as dead"); dead_instances.push(id.clone()); instances_to_remove.push(id.clone()); } @@ -579,26 +589,35 @@ impl CamoufoxNodecarLauncher { let mut inner = self.inner.lock().await; for id in &instances_to_remove { inner.instances.remove(id); + println!("Removed dead Camoufox instance: {id}"); } } Ok(dead_instances) } - /// Check if a Camoufox server is running on the given port + /// Check if a Camoufox server is running on the given port (which is actually a PID) async fn is_server_running(&self, port: u32) -> bool { - let client = reqwest::Client::new(); - let url = format!("http://localhost:{port}/json/version"); + // For Camoufox, the "port" is actually the process PID + // Check if the process is still running + use sysinfo::{Pid, System}; - match client - .get(&url) - .timeout(std::time::Duration::from_secs(1)) - .send() - .await - { - Ok(response) => response.status().is_success(), - Err(_) => false, + let system = System::new_all(); + if let Some(process) = system.process(Pid::from(port as usize)) { + // Check if this is actually a Camoufox process by looking at the command line + let cmd = process.cmd(); + let is_camoufox = cmd.iter().any(|arg| { + let arg_str = arg.to_str().unwrap_or(""); + arg_str.contains("camoufox-worker") || arg_str.contains("camoufox") + }); + + if is_camoufox { + println!("Found running Camoufox process with PID: {port}"); + return true; + } } + + false } } diff --git a/src-tauri/src/geoip_downloader.rs b/src-tauri/src/geoip_downloader.rs index 876be50..f866cc3 100644 --- a/src-tauri/src/geoip_downloader.rs +++ b/src-tauri/src/geoip_downloader.rs @@ -27,6 +27,12 @@ impl GeoIPDownloader { } } + /// Create a new downloader with custom client (for testing) + #[cfg(test)] + pub fn new_with_client(client: Client) -> Self { + Self { client } + } + fn get_cache_dir() -> Result> { let base_dirs = BaseDirs::new().ok_or("Failed to determine base directories")?; @@ -169,3 +175,125 @@ impl GeoIPDownloader { Ok(releases) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::browser::GithubRelease; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn create_mock_release() -> GithubRelease { + GithubRelease { + tag_name: "v1.0.0".to_string(), + name: "Test Release".to_string(), + body: Some("Test release body".to_string()), + published_at: "2023-01-01T00:00:00Z".to_string(), + created_at: Some("2023-01-01T00:00:00Z".to_string()), + html_url: Some("https://example.com/release".to_string()), + tarball_url: Some("https://example.com/tarball".to_string()), + zipball_url: Some("https://example.com/zipball".to_string()), + draft: false, + prerelease: false, + is_nightly: false, + id: Some(1), + node_id: Some("test_node_id".to_string()), + target_commitish: None, + assets: vec![crate::browser::GithubAsset { + id: Some(1), + node_id: Some("test_asset_node_id".to_string()), + name: "GeoLite2-City.mmdb".to_string(), + label: None, + content_type: Some("application/octet-stream".to_string()), + state: Some("uploaded".to_string()), + size: 1024, + download_count: Some(0), + created_at: Some("2023-01-01T00:00:00Z".to_string()), + updated_at: Some("2023-01-01T00:00:00Z".to_string()), + browser_download_url: "https://example.com/GeoLite2-City.mmdb".to_string(), + }], + } + } + + #[tokio::test] + async fn test_fetch_geoip_releases_success() { + let mock_server = MockServer::start().await; + let releases = vec![create_mock_release()]; + + Mock::given(method("GET")) + .and(path(format!("/repos/{MMDB_REPO}/releases"))) + .respond_with(ResponseTemplate::new(200).set_body_json(&releases)) + .mount(&mock_server) + .await; + + let client = Client::builder() + .build() + .expect("Failed to create HTTP client"); + + let downloader = GeoIPDownloader::new_with_client(client); + + // Override the URL for testing + let url = format!("{}/repos/{}/releases", mock_server.uri(), MMDB_REPO); + let response = downloader + .client + .get(&url) + .header("User-Agent", "Mozilla/5.0 (compatible; donutbrowser)") + .send() + .await + .expect("Request should succeed"); + + assert!(response.status().is_success()); + + let fetched_releases: Vec = response.json().await.expect("Should parse JSON"); + assert_eq!(fetched_releases.len(), 1); + assert_eq!(fetched_releases[0].tag_name, "v1.0.0"); + } + + #[tokio::test] + async fn test_find_city_mmdb_asset() { + let downloader = GeoIPDownloader::new(); + let release = create_mock_release(); + + let asset_url = downloader.find_city_mmdb_asset(&release); + assert!(asset_url.is_some()); + assert_eq!(asset_url.unwrap(), "https://example.com/GeoLite2-City.mmdb"); + } + + #[tokio::test] + async fn test_find_city_mmdb_asset_not_found() { + let downloader = GeoIPDownloader::new(); + let mut release = create_mock_release(); + release.assets[0].name = "wrong-file.txt".to_string(); + + let asset_url = downloader.find_city_mmdb_asset(&release); + assert!(asset_url.is_none()); + } + + #[test] + fn test_get_cache_dir() { + let cache_dir = GeoIPDownloader::get_cache_dir(); + assert!(cache_dir.is_ok()); + + let path = cache_dir.unwrap(); + assert!(path.to_string_lossy().contains("camoufox")); + } + + #[test] + fn test_get_mmdb_file_path() { + let mmdb_path = GeoIPDownloader::get_mmdb_file_path(); + assert!(mmdb_path.is_ok()); + + let path = mmdb_path.unwrap(); + assert!(path.to_string_lossy().ends_with("GeoLite2-City.mmdb")); + } + + #[test] + fn test_is_geoip_database_available() { + // This test will return false unless the database actually exists + // In a real environment, this would check the actual file system + let is_available = GeoIPDownloader::is_geoip_database_available(); + // We can't assert a specific value since it depends on the system state + // But we can verify the function doesn't panic + println!("GeoIP database available: {is_available}"); + } +} diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 0bd8910..1804473 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -335,20 +335,30 @@ impl ProfileManager { Ok(()) } - pub fn update_camoufox_config( + pub async fn update_camoufox_config( &self, + app_handle: tauri::AppHandle, profile_name: &str, config: CamoufoxConfig, - ) -> Result<(), Box> { + ) -> Result<(), Box> { // Find the profile by name - let profiles = self.list_profiles()?; + let profiles = + self + .list_profiles() + .map_err(|e| -> Box { + format!("Failed to list profiles: {e}").into() + })?; let mut profile = profiles .into_iter() .find(|p| p.name == profile_name) - .ok_or_else(|| format!("Profile {profile_name} not found"))?; + .ok_or_else(|| -> Box { + format!("Profile {profile_name} not found").into() + })?; - // Check if the browser is currently running - if profile.process_id.is_some() { + // Check if the browser is currently running using the comprehensive status check + let is_running = self.check_browser_status(app_handle, &profile).await?; + + if is_running { return Err( "Cannot update Camoufox configuration while browser is running. Please stop the browser first.".into(), ); @@ -358,7 +368,11 @@ impl ProfileManager { profile.camoufox_config = Some(config); // Save the updated profile - self.save_profile(&profile)?; + self + .save_profile(&profile) + .map_err(|e| -> Box { + format!("Failed to save profile: {e}").into() + })?; println!("Camoufox configuration updated for profile '{profile_name}'."); @@ -433,9 +447,6 @@ impl ProfileManager { .map_err(|e| format!("Failed to update profile proxy: {e}"))?; println!("Successfully started proxy for profile: {}", profile.name); - - // Give the proxy a moment to fully start up - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; } Err(e) => { eprintln!("Failed to start proxy: {e}"); @@ -498,10 +509,14 @@ impl ProfileManager { app_handle: tauri::AppHandle, profile: &BrowserProfile, ) -> Result> { - // Handle camoufox profiles using the same fast approach as other browsers - // No special handling needed - camoufox uses the same process checking logic + // Handle Camoufox profiles using nodecar-based status checking + if profile.browser == "camoufox" { + return self + .check_camoufox_status_via_nodecar(&app_handle, profile) + .await; + } - // For non-camoufox browsers, use the existing logic + // For non-camoufox browsers, use the existing PID-based logic let mut inner_profile = profile.clone(); let system = System::new_all(); let mut is_running = false; @@ -517,12 +532,8 @@ impl ProfileManager { let profile_data_path_str = profile_data_path.to_string_lossy(); let profile_path_match = cmd.iter().any(|s| { let arg = s.to_str().unwrap_or(""); - // For Firefox-based browsers (including camoufox), check for exact profile path match - if profile.browser == "camoufox" { - // Camoufox uses user_data_dir like Chromium browsers - arg.contains(&format!("--user-data-dir={profile_data_path_str}")) - || arg == profile_data_path_str - } else if profile.browser == "tor-browser" + // For Firefox-based browsers, check for exact profile path match + if profile.browser == "tor-browser" || profile.browser == "firefox" || profile.browser == "firefox-developer" || profile.browser == "mullvad-browser" @@ -577,13 +588,7 @@ impl ProfileManager { "zen" => exe_name.contains("zen"), "chromium" => exe_name.contains("chromium"), "brave" => exe_name.contains("brave"), - "camoufox" => { - exe_name.contains("camoufox") - || (exe_name.contains("firefox") - && cmd - .iter() - .any(|arg| arg.to_str().unwrap_or("").contains("camoufox"))) - } + // Camoufox is handled via nodecar, not PID-based checking _ => false, }; @@ -660,6 +665,77 @@ impl ProfileManager { Ok(is_running) } + // Check Camoufox status using nodecar-based approach + async fn check_camoufox_status_via_nodecar( + &self, + app_handle: &tauri::AppHandle, + profile: &BrowserProfile, + ) -> Result> { + use crate::camoufox::CamoufoxNodecarLauncher; + + let launcher = CamoufoxNodecarLauncher::new(app_handle.clone()); + let profiles_dir = self.get_profiles_dir(); + let profile_data_path = profile.get_profile_data_path(&profiles_dir); + let profile_path_str = profile_data_path.to_string_lossy(); + + // Check if there's a running Camoufox instance for this profile + match launcher.find_camoufox_by_profile(&profile_path_str).await { + Ok(Some(camoufox_process)) => { + // Found a running instance, update profile with process info + let mut updated_profile = profile.clone(); + updated_profile.process_id = camoufox_process.port; + if let Err(e) = self.save_profile(&updated_profile) { + println!("Warning: Failed to update Camoufox profile with process info: {e}"); + } + + // Emit profile update event to frontend + if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { + println!("Warning: Failed to emit profile update event: {e}"); + } + + println!( + "Camoufox profile '{}' is running with PID: {:?}", + profile.name, camoufox_process.port + ); + Ok(true) + } + Ok(None) => { + // No running instance found, clear process ID if set + if profile.process_id.is_some() { + let mut updated_profile = profile.clone(); + updated_profile.process_id = None; + if let Err(e) = self.save_profile(&updated_profile) { + println!("Warning: Failed to clear Camoufox profile process info: {e}"); + } + + // Emit profile update event to frontend + if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { + println!("Warning: Failed to emit profile update event: {e}"); + } + } + println!("Camoufox profile '{}' is not running", profile.name); + Ok(false) + } + Err(e) => { + // Error checking status, assume not running and clear process ID + println!("Warning: Failed to check Camoufox status via nodecar: {e}"); + if profile.process_id.is_some() { + let mut updated_profile = profile.clone(); + updated_profile.process_id = None; + if let Err(e) = self.save_profile(&updated_profile) { + println!("Warning: Failed to clear Camoufox profile process info after error: {e}"); + } + + // Emit profile update event to frontend + if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { + println!("Warning: Failed to emit profile update event: {e}"); + } + } + Ok(false) + } + } + } + // Helper function to check if a process matches TOR/Mullvad browser fn is_tor_or_mullvad_browser( &self, diff --git a/src-tauri/tests/common/mod.rs b/src-tauri/tests/common/mod.rs new file mode 100644 index 0000000..2b5333c --- /dev/null +++ b/src-tauri/tests/common/mod.rs @@ -0,0 +1,180 @@ +use std::env; +use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; +use tokio::time::timeout; + +/// Utility functions for integration tests +pub struct TestUtils; + +impl TestUtils { + /// Build the nodecar binary if it doesn't exist + pub async fn ensure_nodecar_binary() -> Result> + { + let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR")?; + let project_root = PathBuf::from(cargo_manifest_dir) + .parent() + .unwrap() + .to_path_buf(); + let nodecar_dir = project_root.join("nodecar"); + let nodecar_binary = nodecar_dir.join("nodecar-bin"); + + // Check if binary already exists + if nodecar_binary.exists() { + return Ok(nodecar_binary); + } + + println!("Building nodecar binary for integration tests..."); + + // Install dependencies + let install_status = Command::new("pnpm") + .args(["install", "--frozen-lockfile"]) + .current_dir(&nodecar_dir) + .status()?; + + if !install_status.success() { + return Err("Failed to install nodecar dependencies".into()); + } + + // Build the binary + let build_status = Command::new("pnpm") + .args(["run", "build"]) + .current_dir(&nodecar_dir) + .status()?; + + if !build_status.success() { + return Err("Failed to build nodecar binary".into()); + } + + if !nodecar_binary.exists() { + return Err("Nodecar binary was not created successfully".into()); + } + + Ok(nodecar_binary) + } + + /// Get the appropriate build target for the current platform + #[allow(dead_code)] + fn get_build_target() -> &'static str { + if cfg!(target_arch = "aarch64") && cfg!(target_os = "macos") { + "build:mac-aarch64" + } else if cfg!(target_arch = "x86_64") && cfg!(target_os = "macos") { + "build:mac-x86_64" + } else if cfg!(target_arch = "x86_64") && cfg!(target_os = "linux") { + "build:linux-x64" + } else if cfg!(target_arch = "aarch64") && cfg!(target_os = "linux") { + "build:linux-arm64" + } else if cfg!(target_arch = "x86_64") && cfg!(target_os = "windows") { + "build:win-x64" + } else if cfg!(target_arch = "aarch64") && cfg!(target_os = "windows") { + "build:win-arm64" + } else { + panic!("Unsupported target architecture for nodecar build") + } + } + + /// Execute a nodecar command with timeout + pub async fn execute_nodecar_command( + binary_path: &PathBuf, + args: &[&str], + timeout_secs: u64, + ) -> Result> { + let mut cmd = Command::new(binary_path); + cmd.args(args); + + // Add environment variable to ensure nodecar doesn't hang + cmd.env("NODE_ENV", "test"); + + let output = timeout(Duration::from_secs(timeout_secs), async { + tokio::process::Command::from(cmd).output().await + }) + .await??; + + Ok(output) + } + + /// Check if a port is available + pub async fn is_port_available(port: u16) -> bool { + tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")) + .await + .is_ok() + } + + /// Wait for a port to become available or occupied + pub async fn wait_for_port_state(port: u16, should_be_occupied: bool, timeout_secs: u64) -> bool { + let start = std::time::Instant::now(); + + while start.elapsed().as_secs() < timeout_secs { + let is_available = Self::is_port_available(port).await; + + if should_be_occupied && !is_available { + return true; // Port is occupied as expected + } else if !should_be_occupied && is_available { + return true; // Port is available as expected + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } + + false + } + + /// Create a temporary directory for test files + pub fn create_temp_dir() -> Result> { + Ok(tempfile::tempdir()?) + } + + /// Clean up all running nodecar processes (proxies and camoufox instances) + pub async fn cleanup_all_nodecar_processes( + nodecar_path: &PathBuf, + ) -> Result<(), Box> { + println!("Cleaning up all nodecar processes..."); + + // Get list of all proxies and stop them individually + let proxy_list_args = ["proxy", "list"]; + if let Ok(list_output) = Self::execute_nodecar_command(nodecar_path, &proxy_list_args, 10).await + { + if list_output.status.success() { + let list_stdout = String::from_utf8(list_output.stdout)?; + if let Ok(proxies) = serde_json::from_str::(&list_stdout) { + if let Some(proxy_array) = proxies.as_array() { + for proxy in proxy_array { + if let Some(proxy_id) = proxy["id"].as_str() { + let stop_args = ["proxy", "stop", "--id", proxy_id]; + let _ = Self::execute_nodecar_command(nodecar_path, &stop_args, 10).await; + println!("Stopped proxy: {proxy_id}"); + } + } + } + } + } + } + + // Get list of all camoufox instances and stop them individually + let camoufox_list_args = ["camoufox", "list"]; + if let Ok(list_output) = + Self::execute_nodecar_command(nodecar_path, &camoufox_list_args, 10).await + { + if list_output.status.success() { + let list_stdout = String::from_utf8(list_output.stdout)?; + if let Ok(instances) = serde_json::from_str::(&list_stdout) { + if let Some(instance_array) = instances.as_array() { + for instance in instance_array { + if let Some(instance_id) = instance["id"].as_str() { + let stop_args = ["camoufox", "stop", "--id", instance_id]; + let _ = Self::execute_nodecar_command(nodecar_path, &stop_args, 30).await; + println!("Stopped camoufox instance: {instance_id}"); + } + } + } + } + } + } + + // Give processes time to clean up + tokio::time::sleep(Duration::from_secs(2)).await; + + println!("Nodecar process cleanup completed"); + Ok(()) + } +} diff --git a/src-tauri/tests/nodecar_integration.rs b/src-tauri/tests/nodecar_integration.rs new file mode 100644 index 0000000..34b9973 --- /dev/null +++ b/src-tauri/tests/nodecar_integration.rs @@ -0,0 +1,767 @@ +mod common; +use common::TestUtils; +use serde_json::Value; + +/// Setup function to ensure clean state before tests +async fn setup_test() -> Result> { + let nodecar_path = TestUtils::ensure_nodecar_binary().await?; + + // Clean up any existing processes from previous test runs + let _ = TestUtils::cleanup_all_nodecar_processes(&nodecar_path).await; + + Ok(nodecar_path) +} + +/// Cleanup function to ensure clean state after tests +async fn cleanup_test(nodecar_path: &std::path::PathBuf) { + let _ = TestUtils::cleanup_all_nodecar_processes(nodecar_path).await; +} + +/// Helper function to stop a specific camoufox by ID +async fn stop_camoufox_by_id( + nodecar_path: &std::path::PathBuf, + camoufox_id: &str, +) -> Result<(), Box> { + let stop_args = ["camoufox", "stop", "--id", camoufox_id]; + let _ = TestUtils::execute_nodecar_command(nodecar_path, &stop_args, 10).await?; + Ok(()) +} + +/// Integration tests for nodecar proxy functionality +#[tokio::test] +async fn test_nodecar_proxy_lifecycle() -> Result<(), Box> { + let nodecar_path = setup_test().await?; + + // Test proxy start with a known working upstream + let args = [ + "proxy", + "start", + "--host", + "httpbin.org", + "--proxy-port", + "80", + "--type", + "http", + ]; + + println!("Starting proxy with nodecar..."); + let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 30).await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into()); + } + + let stdout = String::from_utf8(output.stdout)?; + let config: Value = serde_json::from_str(&stdout)?; + + // Verify proxy configuration structure + assert!(config["id"].is_string(), "Proxy ID should be a string"); + assert!( + config["localPort"].is_number(), + "Local port should be a number" + ); + assert!( + config["localUrl"].is_string(), + "Local URL should be a string" + ); + + let proxy_id = config["id"].as_str().unwrap(); + let local_port = config["localPort"].as_u64().unwrap() as u16; + + println!("Proxy started with ID: {proxy_id} on port: {local_port}"); + + // Wait for the proxy to start listening + let is_listening = TestUtils::wait_for_port_state(local_port, true, 10).await; + assert!( + is_listening, + "Proxy should be listening on the assigned port" + ); + + // Test stopping the proxy + let stop_args = ["proxy", "stop", "--id", proxy_id]; + let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 10).await?; + + assert!(stop_output.status.success(), "Proxy stop should succeed"); + + let port_available = TestUtils::wait_for_port_state(local_port, false, 5).await; + assert!( + port_available, + "Port should be available after stopping proxy" + ); + + cleanup_test(&nodecar_path).await; + Ok(()) +} + +/// Test proxy with authentication +#[tokio::test] +async fn test_nodecar_proxy_with_auth() -> Result<(), Box> { + let nodecar_path = setup_test().await?; + + let args = [ + "proxy", + "start", + "--host", + "httpbin.org", + "--proxy-port", + "80", + "--type", + "http", + "--username", + "testuser", + "--password", + "testpass", + ]; + + let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 30).await?; + + if output.status.success() { + let stdout = String::from_utf8(output.stdout)?; + let config: Value = serde_json::from_str(&stdout)?; + + // Clean up + let proxy_id = config["id"].as_str().unwrap(); + let stop_args = ["proxy", "stop", "--id", proxy_id]; + let _ = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 10).await; + + // Verify upstream URL contains encoded credentials + if let Some(upstream_url) = config["upstreamUrl"].as_str() { + assert!( + upstream_url.contains("testuser"), + "Upstream URL should contain username" + ); + // Password might be encoded, so we check for the presence of auth info + assert!( + upstream_url.contains("@"), + "Upstream URL should contain auth separator" + ); + } + } + + cleanup_test(&nodecar_path).await; + Ok(()) +} + +/// Test proxy list functionality +#[tokio::test] +async fn test_nodecar_proxy_list() -> Result<(), Box> { + let nodecar_path = setup_test().await?; + + // Start a proxy first + let start_args = [ + "proxy", + "start", + "--host", + "httpbin.org", + "--proxy-port", + "80", + "--type", + "http", + ]; + + let start_output = TestUtils::execute_nodecar_command(&nodecar_path, &start_args, 30).await?; + + if start_output.status.success() { + let stdout = String::from_utf8(start_output.stdout)?; + let config: Value = serde_json::from_str(&stdout)?; + let proxy_id = config["id"].as_str().unwrap(); + + // Test list command + let list_args = ["proxy", "list"]; + let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args, 10).await?; + + assert!(list_output.status.success(), "Proxy list should succeed"); + + let list_stdout = String::from_utf8(list_output.stdout)?; + let proxy_list: Value = serde_json::from_str(&list_stdout)?; + + assert!(proxy_list.is_array(), "Proxy list should be an array"); + + let proxies = proxy_list.as_array().unwrap(); + assert!( + !proxies.is_empty(), + "Should have at least one proxy in the list" + ); + + // Find our proxy in the list + let found_proxy = proxies.iter().find(|p| p["id"].as_str() == Some(proxy_id)); + assert!(found_proxy.is_some(), "Started proxy should be in the list"); + + // Clean up + let stop_args = ["proxy", "stop", "--id", proxy_id]; + let _ = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 10).await; + } + + cleanup_test(&nodecar_path).await; + Ok(()) +} + +/// Test Camoufox functionality +#[tokio::test] +async fn test_nodecar_camoufox_lifecycle() -> Result<(), Box> { + let nodecar_path = setup_test().await?; + + let temp_dir = TestUtils::create_temp_dir()?; + let profile_path = temp_dir.path().join("test_profile"); + + let args = [ + "camoufox", + "start", + "--profile-path", + profile_path.to_str().unwrap(), + "--headless", + "--debug", + ]; + + println!("Starting Camoufox with nodecar..."); + let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 35).await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + + // If Camoufox is not installed or times out, skip the test + if stderr.contains("not installed") + || stderr.contains("not found") + || stderr.contains("timeout") + || stdout.contains("timeout") + { + println!("Skipping Camoufox test - Camoufox not available or timed out"); + cleanup_test(&nodecar_path).await; + return Ok(()); + } + + cleanup_test(&nodecar_path).await; + return Err(format!("Camoufox start failed - stdout: {stdout}, stderr: {stderr}").into()); + } + + let stdout = String::from_utf8(output.stdout)?; + let config: Value = serde_json::from_str(&stdout)?; + + // Verify Camoufox configuration structure + assert!(config["id"].is_string(), "Camoufox ID should be a string"); + + let camoufox_id = config["id"].as_str().unwrap(); + println!("Camoufox started with ID: {camoufox_id}"); + + // Test stopping Camoufox + let stop_args = ["camoufox", "stop", "--id", camoufox_id]; + let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 30).await?; + + assert!(stop_output.status.success(), "Camoufox stop should succeed"); + + cleanup_test(&nodecar_path).await; + Ok(()) +} + +/// Test Camoufox with URL opening +#[tokio::test] +async fn test_nodecar_camoufox_with_url() -> Result<(), Box> { + let nodecar_path = setup_test().await?; + + let temp_dir = TestUtils::create_temp_dir()?; + let profile_path = temp_dir.path().join("test_profile_url"); + + let args = [ + "camoufox", + "start", + "--profile-path", + profile_path.to_str().unwrap(), + "--url", + "https://httpbin.org/get", + "--headless", + "--debug", + ]; + + let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 15).await?; + + if output.status.success() { + let stdout = String::from_utf8(output.stdout)?; + let config: Value = serde_json::from_str(&stdout)?; + + let camoufox_id = config["id"].as_str().unwrap(); + + // Verify URL is set + if let Some(url) = config["url"].as_str() { + assert_eq!( + url, "https://httpbin.org/get", + "URL should match what was provided" + ); + } + + // Clean up + let _ = stop_camoufox_by_id(&nodecar_path, camoufox_id).await; + } else { + println!("Skipping Camoufox URL test - likely not installed"); + return Ok(()); + } + + cleanup_test(&nodecar_path).await; + Ok(()) +} + +/// Test Camoufox list functionality +#[tokio::test] +async fn test_nodecar_camoufox_list() -> Result<(), Box> { + let nodecar_path = setup_test().await?; + + // Test list command (should work even without Camoufox installed) + let list_args = ["camoufox", "list"]; + let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args, 10).await?; + + assert!(list_output.status.success(), "Camoufox list should succeed"); + + let list_stdout = String::from_utf8(list_output.stdout)?; + let camoufox_list: Value = serde_json::from_str(&list_stdout)?; + + assert!(camoufox_list.is_array(), "Camoufox list should be an array"); + + cleanup_test(&nodecar_path).await; + Ok(()) +} + +/// Test Camoufox process tracking and management +#[tokio::test] +async fn test_nodecar_camoufox_process_tracking( +) -> Result<(), Box> { + let nodecar_path = setup_test().await?; + + let temp_dir = TestUtils::create_temp_dir()?; + let profile_path = temp_dir.path().join("test_profile_tracking"); + + // Start multiple Camoufox instances + let mut instance_ids: Vec = Vec::new(); + + for i in 0..2 { + let instance_profile_path = format!("{}_instance_{}", profile_path.to_str().unwrap(), i); + let args = [ + "camoufox", + "start", + "--profile-path", + &instance_profile_path, + "--headless", + "--debug", + ]; + + println!("Starting Camoufox instance {i}..."); + let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 10).await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + + // If Camoufox is not installed, skip the test + if stderr.contains("not installed") || stderr.contains("not found") { + println!("Skipping Camoufox process tracking test - Camoufox not installed"); + + // Clean up any instances that were started + for instance_id in &instance_ids { + let stop_args = ["camoufox", "stop", "--id", instance_id]; + let _ = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 30).await; + } + + return Ok(()); + } + + return Err( + format!("Camoufox instance {i} start failed - stdout: {stdout}, stderr: {stderr}").into(), + ); + } + + let stdout = String::from_utf8(output.stdout)?; + let config: Value = serde_json::from_str(&stdout)?; + + let camoufox_id = config["id"].as_str().unwrap().to_string(); + instance_ids.push(camoufox_id.clone()); + println!("Camoufox instance {i} started with ID: {camoufox_id}"); + } + + // Verify all instances are tracked + let list_args = ["camoufox", "list"]; + let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args, 10).await?; + + assert!(list_output.status.success(), "Camoufox list should succeed"); + + let list_stdout = String::from_utf8(list_output.stdout)?; + println!("Camoufox list output: {}", list_stdout); + let instances: Value = serde_json::from_str(&list_stdout)?; + + let instances_array = instances.as_array().unwrap(); + println!("Found {} instances in list", instances_array.len()); + + // Verify our instances are in the list + for instance_id in &instance_ids { + let instance_found = instances_array + .iter() + .any(|i| i["id"].as_str() == Some(instance_id)); + if !instance_found { + println!( + "Instance {} not found in list. Available instances:", + instance_id + ); + for instance in instances_array { + if let Some(id) = instance["id"].as_str() { + println!(" - {}", id); + } + } + } + assert!( + instance_found, + "Camoufox instance {instance_id} should be found in list" + ); + } + + // Stop all instances individually + for instance_id in &instance_ids { + println!("Stopping Camoufox instance: {instance_id}"); + let stop_args = ["camoufox", "stop", "--id", instance_id]; + let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 30).await?; + + assert!( + stop_output.status.success(), + "Camoufox stop should succeed for instance {instance_id}" + ); + + let stop_stdout = String::from_utf8(stop_output.stdout)?; + let stop_result: Value = serde_json::from_str(&stop_stdout)?; + assert!( + stop_result["success"].as_bool().unwrap_or(false), + "Stop result should indicate success for instance {instance_id}" + ); + } + + // Verify all instances are removed + let list_output_after = TestUtils::execute_nodecar_command(&nodecar_path, &list_args, 10).await?; + + let instances_after: Value = serde_json::from_str(&String::from_utf8(list_output_after.stdout)?)?; + let instances_after_array = instances_after.as_array().unwrap(); + + for instance_id in &instance_ids { + let instance_still_exists = instances_after_array + .iter() + .any(|i| i["id"].as_str() == Some(instance_id)); + assert!( + !instance_still_exists, + "Stopped Camoufox instance {instance_id} should not be found in list" + ); + } + + println!("Camoufox process tracking test completed successfully"); + cleanup_test(&nodecar_path).await; + Ok(()) +} + +/// Test Camoufox with various configuration options +#[tokio::test] +async fn test_nodecar_camoufox_configuration_options( +) -> Result<(), Box> { + let nodecar_path = setup_test().await?; + + let temp_dir = TestUtils::create_temp_dir()?; + let profile_path = temp_dir.path().join("test_profile_config"); + + let args = [ + "camoufox", + "start", + "--profile-path", + profile_path.to_str().unwrap(), + "--headless", + "--debug", + "--os", + "linux", + "--block-images", + "--humanize", + "--locale", + "en-US,en-GB", + "--timezone", + "America/New_York", + "--disable-cache", + ]; + + println!("Starting Camoufox with configuration options..."); + let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 15).await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + + // If Camoufox is not installed, skip the test + if stderr.contains("not installed") || stderr.contains("not found") { + println!("Skipping Camoufox configuration test - Camoufox not installed"); + return Ok(()); + } + + return Err( + format!("Camoufox with config start failed - stdout: {stdout}, stderr: {stderr}").into(), + ); + } + + let stdout = String::from_utf8(output.stdout)?; + let config: Value = serde_json::from_str(&stdout)?; + + let camoufox_id = config["id"].as_str().unwrap(); + println!("Camoufox with configuration started with ID: {camoufox_id}"); + + // Verify configuration was applied by checking the profile path + if let Some(returned_profile_path) = config["profilePath"].as_str() { + assert!( + returned_profile_path.contains("test_profile_config"), + "Profile path should match what was provided" + ); + } + + // Clean up + let _ = stop_camoufox_by_id(&nodecar_path, camoufox_id).await; + + println!("Camoufox configuration test completed successfully"); + cleanup_test(&nodecar_path).await; + Ok(()) +} + +/// Test nodecar command validation +#[tokio::test] +async fn test_nodecar_command_validation() -> Result<(), Box> { + let nodecar_path = setup_test().await?; + + // Test invalid command + let invalid_args = ["invalid", "command"]; + let output = TestUtils::execute_nodecar_command(&nodecar_path, &invalid_args, 10).await?; + + assert!(!output.status.success(), "Invalid command should fail"); + + // Test proxy without required arguments + let incomplete_args = ["proxy", "start"]; + let output = TestUtils::execute_nodecar_command(&nodecar_path, &incomplete_args, 10).await?; + + assert!( + !output.status.success(), + "Incomplete proxy command should fail" + ); + + cleanup_test(&nodecar_path).await; + Ok(()) +} + +/// Test concurrent proxy operations +#[tokio::test] +async fn test_nodecar_concurrent_proxies() -> Result<(), Box> { + let nodecar_path = setup_test().await?; + + // Start multiple proxies concurrently + let mut handles = vec![]; + let mut proxy_ids: Vec = vec![]; + + for i in 0..3 { + let nodecar_path_clone = nodecar_path.clone(); + let handle = tokio::spawn(async move { + let args = [ + "proxy", + "start", + "--host", + "httpbin.org", + "--proxy-port", + "80", + "--type", + "http", + ]; + + TestUtils::execute_nodecar_command(&nodecar_path_clone, &args, 30).await + }); + handles.push((i, handle)); + } + + // Wait for all proxies to start + for (i, handle) in handles { + match handle.await.map_err(|e| format!("Join error: {e}"))? { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8(output.stdout)?; + let config: Value = serde_json::from_str(&stdout)?; + let proxy_id = config["id"].as_str().unwrap().to_string(); + proxy_ids.push(proxy_id); + println!("Proxy {i} started successfully"); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("Proxy {i} failed to start: {stderr}"); + } + Err(e) => { + println!("Proxy {i} error: {e}"); + } + } + } + + // Clean up all started proxies + for proxy_id in proxy_ids { + let stop_args = ["proxy", "stop", "--id", &proxy_id]; + let _ = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 10).await; + } + + cleanup_test(&nodecar_path).await; + Ok(()) +} + +/// Test proxy with different upstream types +#[tokio::test] +async fn test_nodecar_proxy_types() -> Result<(), Box> { + let nodecar_path = setup_test().await?; + + let test_cases = vec![ + ("http", "httpbin.org", "80"), + ("https", "httpbin.org", "443"), + ]; + + for (proxy_type, host, port) in test_cases { + println!("Testing {proxy_type} proxy to {host}:{port}"); + + let args = [ + "proxy", + "start", + "--host", + host, + "--proxy-port", + port, + "--type", + proxy_type, + ]; + + let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 30).await?; + + if output.status.success() { + let stdout = String::from_utf8(output.stdout)?; + let config: Value = serde_json::from_str(&stdout)?; + let proxy_id = config["id"].as_str().unwrap(); + + // Clean up + let stop_args = ["proxy", "stop", "--id", proxy_id]; + let _ = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 10).await; + + println!("{proxy_type} proxy test passed"); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("{proxy_type} proxy test failed: {stderr}"); + } + } + + cleanup_test(&nodecar_path).await; + Ok(()) +} + +/// Test SOCKS5 proxy chaining - create two proxies where the second uses the first as upstream +#[tokio::test] +async fn test_nodecar_socks5_proxy_chaining() -> Result<(), Box> +{ + let nodecar_path = setup_test().await?; + + // Step 1: Start a SOCKS5 proxy with a known working upstream (httpbin.org) + let socks5_args = [ + "proxy", + "start", + "--host", + "httpbin.org", + "--proxy-port", + "80", + "--type", + "http", // Use HTTP upstream for the first proxy + ]; + + println!("Starting first proxy with HTTP upstream..."); + let socks5_output = TestUtils::execute_nodecar_command(&nodecar_path, &socks5_args, 30).await?; + + if !socks5_output.status.success() { + let stderr = String::from_utf8_lossy(&socks5_output.stderr); + let stdout = String::from_utf8_lossy(&socks5_output.stdout); + return Err(format!("First proxy start failed - stdout: {stdout}, stderr: {stderr}").into()); + } + + let socks5_stdout = String::from_utf8(socks5_output.stdout)?; + let socks5_config: Value = serde_json::from_str(&socks5_stdout)?; + + let socks5_proxy_id = socks5_config["id"].as_str().unwrap(); + let socks5_local_port = socks5_config["localPort"].as_u64().unwrap() as u16; + + println!("First proxy started with ID: {socks5_proxy_id} on port: {socks5_local_port}"); + + // Step 2: Start a second proxy that uses the first proxy as upstream + let http_proxy_args = [ + "proxy", + "start", + "--upstream", + &format!("http://127.0.0.1:{socks5_local_port}"), + ]; + + println!("Starting second proxy with first proxy as upstream..."); + let http_output = TestUtils::execute_nodecar_command(&nodecar_path, &http_proxy_args, 30).await?; + + if !http_output.status.success() { + // Clean up first proxy before failing + let stop_socks5_args = ["proxy", "stop", "--id", socks5_proxy_id, "--type", "socks5"]; + let _ = TestUtils::execute_nodecar_command(&nodecar_path, &stop_socks5_args, 10).await; + + let stderr = String::from_utf8_lossy(&http_output.stderr); + let stdout = String::from_utf8_lossy(&http_output.stdout); + return Err( + format!("Second proxy with chained upstream failed - stdout: {stdout}, stderr: {stderr}") + .into(), + ); + } + + let http_stdout = String::from_utf8(http_output.stdout)?; + let http_config: Value = serde_json::from_str(&http_stdout)?; + + let http_proxy_id = http_config["id"].as_str().unwrap(); + let http_local_port = http_config["localPort"].as_u64().unwrap() as u16; + + println!( + "Second proxy started with ID: {http_proxy_id} on port: {http_local_port} (chained through first proxy)" + ); + + // Verify both proxies are listening by waiting for them to be occupied + let socks5_listening = TestUtils::wait_for_port_state(socks5_local_port, true, 5).await; + let http_listening = TestUtils::wait_for_port_state(http_local_port, true, 5).await; + + assert!( + socks5_listening, + "First proxy should be listening on port {socks5_local_port}" + ); + assert!( + http_listening, + "Second proxy should be listening on port {http_local_port}" + ); + + // Clean up both proxies + let stop_http_args = ["proxy", "stop", "--id", http_proxy_id]; + let stop_socks5_args = ["proxy", "stop", "--id", socks5_proxy_id]; + + let http_stop_result = + TestUtils::execute_nodecar_command(&nodecar_path, &stop_http_args, 10).await; + let socks5_stop_result = + TestUtils::execute_nodecar_command(&nodecar_path, &stop_socks5_args, 10).await; + + // Verify cleanup + assert!( + http_stop_result.is_ok() && http_stop_result.unwrap().status.success(), + "Second proxy stop should succeed" + ); + assert!( + socks5_stop_result.is_ok() && socks5_stop_result.unwrap().status.success(), + "First proxy stop should succeed" + ); + + let http_port_available = TestUtils::wait_for_port_state(http_local_port, false, 5).await; + let socks5_port_available = TestUtils::wait_for_port_state(socks5_local_port, false, 5).await; + + assert!( + http_port_available, + "Second proxy port should be available after stopping" + ); + assert!( + socks5_port_available, + "First proxy port should be available after stopping" + ); + + println!("Proxy chaining test completed successfully"); + cleanup_test(&nodecar_path).await; + Ok(()) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3efe4f9..4a39ea7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -229,17 +229,22 @@ export default function Home() { useAppUpdateNotifications(); // Check for startup URLs but only process them once + const [hasCheckedStartupUrl, setHasCheckedStartupUrl] = useState(false); const checkCurrentUrl = useCallback(async () => { + if (hasCheckedStartupUrl) return; + try { const currentUrl = await getCurrent(); if (currentUrl && currentUrl.length > 0) { console.log("Startup URL detected:", currentUrl[0]); void handleUrlOpen(currentUrl[0]); } + setHasCheckedStartupUrl(true); } catch (error) { console.error("Failed to check current URL:", error); + setHasCheckedStartupUrl(true); } - }, [handleUrlOpen]); + }, [handleUrlOpen, hasCheckedStartupUrl]); const checkStartupPrompt = useCallback(async () => { // Only check once during app startup to prevent reopening after dismissing notifications @@ -453,6 +458,9 @@ export default function Home() { const currentRunning = runningProfilesRef.current.has(profile.name); if (isRunning !== currentRunning) { + console.log( + `Profile ${profile.name} (${profile.browser}) status changed: ${currentRunning} -> ${isRunning}`, + ); setRunningProfiles((prev) => { const next = new Set(prev); if (isRunning) { diff --git a/src/components/group-badges.tsx b/src/components/group-badges.tsx index a8b46a1..008eda1 100644 --- a/src/components/group-badges.tsx +++ b/src/components/group-badges.tsx @@ -20,7 +20,9 @@ export function GroupBadges({ if (isLoading) { return (
-
Loading groups...
+
+ Loading groups... +
); } diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 5dab1b3..42b6529 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -103,6 +103,9 @@ export function ProfilesDataTable({ const [profileToDelete, setProfileToDelete] = React.useState(null); const [isDeleting, setIsDeleting] = React.useState(false); + const [launchingProfiles, setLaunchingProfiles] = React.useState>( + new Set(), + ); const [storedProxies, setStoredProxies] = React.useState([]); const [selectedProfiles, setSelectedProfiles] = React.useState>( @@ -365,9 +368,41 @@ export function ProfilesDataTable({ const profile = row.original; const isRunning = browserState.isClient && runningProfiles.has(profile.name); + const isLaunching = launchingProfiles.has(profile.name); const canLaunch = browserState.canLaunchProfile(profile); const tooltipContent = browserState.getLaunchTooltipContent(profile); + const handleLaunchClick = async () => { + if (isRunning) { + console.log( + `Stopping ${profile.browser} profile: ${profile.name}`, + ); + await onKillProfile(profile); + } else { + console.log( + `Launching ${profile.browser} profile: ${profile.name}`, + ); + setLaunchingProfiles((prev) => new Set(prev).add(profile.name)); + try { + await onLaunchProfile(profile); + console.log( + `Successfully launched ${profile.browser} profile: ${profile.name}`, + ); + } catch (error) { + console.error( + `Failed to launch ${profile.browser} profile: ${profile.name}`, + error, + ); + } finally { + setLaunchingProfiles((prev) => { + const next = new Set(prev); + next.delete(profile.name); + return next; + }); + } + } + }; + return (
@@ -376,18 +411,22 @@ export function ProfilesDataTable({ @@ -408,7 +447,7 @@ export function ProfilesDataTable({ onClick={() => column.toggleSorting(column.getIsSorted() === "asc") } - className="h-auto p-0 font-semibold text-left justify-start" + className="h-auto p-0 font-semibold text-left justify-start cursor-pointer" > Name {column.getIsSorted() === "asc" ? ( @@ -448,7 +487,7 @@ export function ProfilesDataTable({ onClick={() => column.toggleSorting(column.getIsSorted() === "asc") } - className="h-auto p-0 font-semibold text-left justify-start" + className="h-auto p-0 font-semibold text-left justify-start cursor-pointer" > Browser {column.getIsSorted() === "asc" ? ( @@ -677,6 +716,7 @@ export function ProfilesDataTable({ onAssignProfilesToGroup, isUpdating, filteredData.length, + launchingProfiles.has, ], ); diff --git a/src/components/profile-selector-dialog.tsx b/src/components/profile-selector-dialog.tsx index 215baea..f8875bf 100644 --- a/src/components/profile-selector-dialog.tsx +++ b/src/components/profile-selector-dialog.tsx @@ -84,15 +84,22 @@ export function ProfileSelectorDialog({ // First, try to find a running profile that can be used for opening links const runningAvailableProfile = profileList.find((profile) => { const isRunning = runningProfiles.has(profile.name); - return isRunning && browserState.canUseProfileForLinks(profile); + // Simple check without browserState dependency + return ( + isRunning && + profile.browser !== "tor-browser" && + profile.browser !== "mullvad-browser" + ); }); if (runningAvailableProfile) { setSelectedProfile(runningAvailableProfile.name); } else { // If no running profile is available, find the first available profile - const availableProfile = profileList.find((profile) => - browserState.canUseProfileForLinks(profile), + const availableProfile = profileList.find( + (profile) => + profile.browser !== "tor-browser" && + profile.browser !== "mullvad-browser", ); if (availableProfile) { setSelectedProfile(availableProfile.name); @@ -104,7 +111,7 @@ export function ProfileSelectorDialog({ } finally { setIsLoading(false); } - }, [runningProfiles, browserState]); + }, [runningProfiles]); // Helper function to get tooltip content for profiles - now uses shared hook const getProfileTooltipContent = (profile: BrowserProfile): string | null => { @@ -227,11 +234,12 @@ export function ProfileSelectorDialog({ return ( - - + +
)}
-
-
+ +
{tooltipContent && ( {tooltipContent} diff --git a/src/hooks/use-version-updater.ts b/src/hooks/use-version-updater.ts index aba9a3c..3e2a458 100644 --- a/src/hooks/use-version-updater.ts +++ b/src/hooks/use-version-updater.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getBrowserDisplayName } from "@/lib/browser-utils"; import { dismissToast, @@ -47,6 +47,9 @@ export function useVersionUpdater() { const [updateProgress, setUpdateProgress] = useState(null); + // Track active downloads to prevent duplicates + const activeDownloads = useRef(new Set()); + const loadUpdateStatus = useCallback(async () => { try { const [lastUpdate, timeUntilNext] = await invoke<[number | null, number]>( @@ -160,6 +163,18 @@ export function useVersionUpdater() { console.log("Browser auto-update event received:", event.payload); const browserDisplayName = getBrowserDisplayName(browser); + const downloadKey = `${browser}-${new_version}`; + + // Check if this download is already in progress + if (activeDownloads.current.has(downloadKey)) { + console.log( + `Download already in progress for ${browserDisplayName} ${new_version}, skipping`, + ); + return; + } + + // Mark download as active + activeDownloads.current.add(downloadKey); try { // Show auto-update start notification @@ -237,6 +252,9 @@ export function useVersionUpdater() { : "Unknown error occurred", duration: 8000, }); + } finally { + // Remove from active downloads + activeDownloads.current.delete(downloadKey); } }; diff --git a/src/types.ts b/src/types.ts index d0975e9..1de6863 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,9 +106,6 @@ export interface CamoufoxConfig { additional_args?: string[]; env_vars?: Record; firefox_prefs?: Record; - // Required options for anti-detect features - disableTheming?: boolean; - showcursor?: boolean; } export interface CamoufoxLaunchResult {