refactor: better camoufox instance tracking

This commit is contained in:
zhom
2025-07-31 03:56:41 +04:00
parent 2fd344b9bb
commit 63000c72bd
20 changed files with 1623 additions and 457 deletions
+7 -1
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+2
View File
@@ -121,6 +121,7 @@
"serde",
"setuptools",
"shadcn",
"showcursor",
"shutil",
"signon",
"signum",
@@ -141,6 +142,7 @@
"tasklist",
"tauri",
"TERX",
"testuser",
"timedatectl",
"titlebar",
"tkinter",
+46 -25
View File
@@ -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<CamoufoxConfig>((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<boolean> {
}
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<void>((resolve) => {
killByPattern.on("exit", () => resolve());
// Timeout after 3 seconds
setTimeout(() => resolve(), 3000);
});
// Delete the configuration
deleteCamoufoxConfig(id);
return true;
+115 -189
View File
@@ -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<void> {
process.exit(1);
}
let server: Awaited<ReturnType<typeof launchServer>> | null = null;
let browser: Awaited<ReturnType<typeof firefox.connect>> | 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();
}
+51 -100
View File
@@ -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<string, unknown> = {};
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<string, unknown> = {};
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({
+9 -3
View File
@@ -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"]
+73 -70
View File
@@ -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}"))
}
+35 -16
View File
@@ -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
}
}
+128
View File
@@ -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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<GithubRelease> = 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}");
}
}
+102 -26
View File
@@ -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<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Find the profile by name
let profiles = self.list_profiles()?;
let profiles =
self
.list_profiles()
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
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<dyn std::error::Error + Send + Sync> {
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<dyn std::error::Error + Send + Sync> {
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<bool, Box<dyn std::error::Error + Send + Sync>> {
// 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<bool, Box<dyn std::error::Error + Send + Sync>> {
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,
+180
View File
@@ -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<PathBuf, Box<dyn std::error::Error + Send + Sync>>
{
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<std::process::Output, Box<dyn std::error::Error + Send + Sync>> {
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<tempfile::TempDir, Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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::<serde_json::Value>(&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::<serde_json::Value>(&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(())
}
}
+767
View File
@@ -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<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<String> = 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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
let nodecar_path = setup_test().await?;
// Start multiple proxies concurrently
let mut handles = vec![];
let mut proxy_ids: Vec<String> = 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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>>
{
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(())
}
+9 -1
View File
@@ -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) {
+3 -1
View File
@@ -20,7 +20,9 @@ export function GroupBadges({
if (isLoading) {
return (
<div className="flex flex-wrap gap-2 mb-4">
<div className="text-sm text-muted-foreground">Loading groups...</div>
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
Loading groups...
</div>
</div>
);
}
+50 -10
View File
@@ -103,6 +103,9 @@ export function ProfilesDataTable({
const [profileToDelete, setProfileToDelete] =
React.useState<BrowserProfile | null>(null);
const [isDeleting, setIsDeleting] = React.useState(false);
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
new Set(),
);
const [storedProxies, setStoredProxies] = React.useState<StoredProxy[]>([]);
const [selectedProfiles, setSelectedProfiles] = React.useState<Set<string>>(
@@ -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 (
<div className="flex gap-2 items-center">
<Tooltip>
@@ -376,18 +411,22 @@ export function ProfilesDataTable({
<Button
variant={isRunning ? "destructive" : "default"}
size="sm"
disabled={!canLaunch}
disabled={!canLaunch || isLaunching}
className={cn(
"cursor-pointer",
"cursor-pointer min-w-[70px]",
!canLaunch && "opacity-50",
)}
onClick={() =>
void (isRunning
? onKillProfile(profile)
: onLaunchProfile(profile))
}
onClick={() => void handleLaunchClick()}
>
{isRunning ? "Stop" : "Launch"}
{isLaunching ? (
<div className="flex items-center gap-1">
<div className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" />
</div>
) : isRunning ? (
"Stop"
) : (
"Launch"
)}
</Button>
</span>
</TooltipTrigger>
@@ -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,
],
);
+19 -11
View File
@@ -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 (
<Tooltip key={profile.name}>
<TooltipTrigger asChild>
<span className="inline-flex">
<SelectItem
value={profile.name}
disabled={!canUseForLinks}
>
<SelectItem
value={profile.name}
disabled={!canUseForLinks}
asChild
>
<span>
<div
className={`flex items-center gap-2 ${
!canUseForLinks ? "opacity-50" : ""
@@ -276,8 +284,8 @@ export function ProfileSelectorDialog({
</Badge>
)}
</div>
</SelectItem>
</span>
</span>
</SelectItem>
</TooltipTrigger>
{tooltipContent && (
<TooltipContent>{tooltipContent}</TooltipContent>
+19 -1
View File
@@ -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<VersionUpdateProgress | null>(null);
// Track active downloads to prevent duplicates
const activeDownloads = useRef(new Set<string>());
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);
}
};
-3
View File
@@ -106,9 +106,6 @@ export interface CamoufoxConfig {
additional_args?: string[];
env_vars?: Record<string, string>;
firefox_prefs?: Record<string, unknown>;
// Required options for anti-detect features
disableTheming?: boolean;
showcursor?: boolean;
}
export interface CamoufoxLaunchResult {