From 29c329b43294bf4b0cffa721f7bb6a5c03f8cd54 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Wed, 11 Jun 2025 01:55:54 +0400 Subject: [PATCH] feat: update proxy ui to accept credential outside url too --- .vscode/settings.json | 2 + nodecar/src/index.ts | 122 ++++++++------ nodecar/src/proxy-runner.ts | 62 ++++--- nodecar/src/proxy-worker.ts | 58 +++++-- nodecar/src/proxy.ts | 103 ------------ package.json | 2 +- src-tauri/Info.plist | 2 + src-tauri/assets/template.pac | 13 +- src-tauri/src/proxy_manager.rs | 245 +++++++++++++++++++++++++--- src/components/window-drag-area.tsx | 2 +- 10 files changed, 375 insertions(+), 236 deletions(-) delete mode 100644 nodecar/src/proxy.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index e64e0ae..a24694b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "ahooks", "appimage", "appindicator", "applescript", @@ -10,6 +11,7 @@ "CFURL", "checkin", "clippy", + "cmdk", "codegen", "devedition", "donutbrowser", diff --git a/nodecar/src/index.ts b/nodecar/src/index.ts index 24d751c..f6957d8 100644 --- a/nodecar/src/index.ts +++ b/nodecar/src/index.ts @@ -1,8 +1,8 @@ import { program } from "commander"; import { startProxyProcess, - stopProxyProcess, stopAllProxyProcesses, + stopProxyProcess, } from "./proxy-runner"; import { listProxyConfigs } from "./proxy-storage"; import { runProxyWorker } from "./proxy-worker"; @@ -11,22 +11,22 @@ import { runProxyWorker } from "./proxy-worker"; program .command("proxy") .argument("", "start, stop, or list proxies") - .option("-h, --host ", "upstream proxy host") - .option("-P, --proxy-port ", "upstream proxy port", Number.parseInt) - .option( - "-t, --type ", - "upstream proxy type (http, https, socks4, socks5)", - "http" - ) - .option("-u, --username ", "upstream proxy username") - .option("-w, --password ", "upstream proxy password") + .option("--host ", "upstream proxy host") + .option("--proxy-port ", "upstream proxy port", Number.parseInt) + .option("--type ", "proxy type (http, https, socks4, socks5)") + .option("--username ", "proxy username") + .option("--password ", "proxy password") .option( "-p, --port ", "local port to use (random if not specified)", - Number.parseInt + Number.parseInt, ) .option("--ignore-certificate", "ignore certificate errors for HTTPS proxies") .option("--id ", "proxy ID for stop command") + .option( + "-u, --upstream ", + "upstream proxy URL (protocol://[username:password@]host:port)", + ) .description("manage proxy servers") .action( async ( @@ -40,78 +40,98 @@ program port?: number; ignoreCertificate?: boolean; id?: string; - } + upstream?: string; + }, ) => { if (action === "start") { - if (!options.host || !options.proxyPort) { - console.error("Error: Upstream proxy host and port are required"); - console.log( - "Example: proxy start -h proxy.example.com -P 8080 -t http -u username -w password" + let upstreamUrl: string; + + // Build upstream URL from individual components if provided + if (options.host && options.proxyPort && options.type) { + const protocol = + options.type === "socks4" || options.type === "socks5" + ? options.type + : "http"; + const auth = + options.username && options.password + ? `${encodeURIComponent(options.username)}:${encodeURIComponent( + options.password, + )}@` + : ""; + upstreamUrl = `${protocol}://${auth}${options.host}:${options.proxyPort}`; + } else if (options.upstream) { + upstreamUrl = options.upstream; + } else { + console.error( + "Error: Either --upstream URL or --host, --proxy-port, and --type are required", ); + console.log( + "Example: proxy start --host datacenter.proxyempire.io --proxy-port 9000 --type http --username user --password pass", + ); + process.exit(1); return; } try { - // Construct the upstream URL with credentials if provided - let upstreamProxyUrl: string; - if (options.username && options.password) { - upstreamProxyUrl = `${options.type}://${options.username}:${options.password}@${options.host}:${options.proxyPort}`; - } else { - upstreamProxyUrl = `${options.type}://${options.host}:${options.proxyPort}`; - } - - const config = await startProxyProcess(upstreamProxyUrl, { + const config = await startProxyProcess(upstreamUrl, { port: options.port, ignoreProxyCertificate: options.ignoreCertificate, }); - console.log(JSON.stringify(config)); + + // Output the configuration as JSON for the Rust side to parse + console.log( + JSON.stringify({ + id: config.id, + localPort: config.localPort, + localUrl: config.localUrl, + upstreamUrl: config.upstreamUrl, + }), + ); + + // Exit successfully to allow the process to detach + process.exit(0); } catch (error: unknown) { - console.error(`Failed to start proxy: ${JSON.stringify(error)}`); + console.error( + `Failed to start proxy: ${ + error instanceof Error ? error.message : JSON.stringify(error) + }`, + ); + process.exit(1); } } else if (action === "stop") { if (options.id) { const stopped = await stopProxyProcess(options.id); - console.log(`{ - "success": ${stopped}}`); - } else if (options.host && options.proxyPort && options.type) { - // Find proxies with matching upstream details - const configs = listProxyConfigs().filter((config) => { - try { - const url = new URL(config.upstreamUrl); - return ( - url.hostname === options.host && - Number.parseInt(url.port) === options.proxyPort && - url.protocol.replace(":", "") === options.type - ); - } catch { - return false; - } - }); + console.log(JSON.stringify({ success: stopped })); + } else if (options.upstream) { + // Find proxies with this upstream URL + const configs = listProxyConfigs().filter( + (config) => config.upstreamUrl === options.upstream, + ); if (configs.length === 0) { - console.error( - `No proxies found for ${options.host}:${options.proxyPort}` - ); + console.error(`No proxies found for ${options.upstream}`); + process.exit(1); return; } for (const config of configs) { const stopped = await stopProxyProcess(config.id); - console.log(`{ - "success": ${stopped}}`); + console.log(JSON.stringify({ success: stopped })); } } else { await stopAllProxyProcesses(); - console.log(`{ - "success": true}`); + console.log(JSON.stringify({ success: true })); } + process.exit(0); } else if (action === "list") { const configs = listProxyConfigs(); console.log(JSON.stringify(configs)); + process.exit(0); } else { console.error("Invalid action. Use 'start', 'stop', or 'list'"); + process.exit(1); } - } + }, ); // Command for proxy worker (internal use) diff --git a/nodecar/src/proxy-runner.ts b/nodecar/src/proxy-runner.ts index daf7b1f..8b327bd 100644 --- a/nodecar/src/proxy-runner.ts +++ b/nodecar/src/proxy-runner.ts @@ -3,12 +3,12 @@ import path from "node:path"; import getPort from "get-port"; import { type ProxyConfig, - saveProxyConfig, - getProxyConfig, deleteProxyConfig, - isProcessRunning, generateProxyId, + getProxyConfig, + isProcessRunning, listProxyConfigs, + saveProxyConfig, } from "./proxy-storage"; /** @@ -19,50 +19,53 @@ import { */ export async function startProxyProcess( upstreamUrl: string, - options: { port?: number; ignoreProxyCertificate?: boolean } = {} + options: { port?: number; ignoreProxyCertificate?: boolean } = {}, ): Promise { // Generate a unique ID for this proxy const id = generateProxyId(); // Get a random available port if not specified - const port = options.port || (await getPort()); + const port = options.port ?? (await getPort()); // Create the proxy configuration const config: ProxyConfig = { id, upstreamUrl, localPort: port, - ignoreProxyCertificate: options.ignoreProxyCertificate || false, + ignoreProxyCertificate: options.ignoreProxyCertificate ?? false, }; // Save the configuration before starting the process saveProxyConfig(config); // Build the command arguments - const args = ["proxy-worker", "start", "--id", id]; + const args = [ + path.join(__dirname, "index.js"), + "proxy-worker", + "start", + "--id", + id, + ]; - // Spawn the process - const child = spawn( - process.execPath, - [path.join(__dirname, "index.js"), ...args], - { - detached: true, - stdio: "ignore", - } - ); + // Spawn the process with proper detachment + const child = spawn(process.execPath, args, { + detached: true, + stdio: ["ignore", "ignore", "ignore"], // Completely ignore all stdio + cwd: process.cwd(), + }); // Unref the child to allow the parent to exit independently child.unref(); - // Store the process ID + // Store the process ID and local URL config.pid = child.pid; - config.localUrl = `http://localhost:${port}`; + config.localUrl = `http://127.0.0.1:${port}`; // Update the configuration with the process ID saveProxyConfig(config); - // Wait a bit to ensure the proxy has started - await new Promise((resolve) => setTimeout(resolve, 500)); + // Give the worker a moment to start before returning + await new Promise((resolve) => setTimeout(resolve, 100)); return config; } @@ -76,6 +79,8 @@ export async function stopProxyProcess(id: string): Promise { const config = getProxyConfig(id); if (!config || !config.pid) { + // Try to delete the config anyway in case it exists without a PID + deleteProxyConfig(id); return false; } @@ -83,10 +88,16 @@ export async function stopProxyProcess(id: string): Promise { // Check if the process is running if (isProcessRunning(config.pid)) { // Send SIGTERM to the process - process.kill(config.pid); + process.kill(config.pid, "SIGTERM"); // Wait a bit to ensure the process has terminated - await new Promise((resolve) => setTimeout(resolve, 300)); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // If still running, send SIGKILL + if (isProcessRunning(config.pid)) { + process.kill(config.pid, "SIGKILL"); + await new Promise((resolve) => setTimeout(resolve, 200)); + } } // Delete the configuration @@ -95,6 +106,8 @@ export async function stopProxyProcess(id: string): Promise { return true; } catch (error) { console.error(`Error stopping proxy ${id}:`, error); + // Delete the configuration even if stopping failed + deleteProxyConfig(id); return false; } } @@ -106,7 +119,6 @@ export async function stopProxyProcess(id: string): Promise { export async function stopAllProxyProcesses(): Promise { const configs = listProxyConfigs(); - for (const config of configs) { - await stopProxyProcess(config.id); - } + const stopPromises = configs.map((config) => stopProxyProcess(config.id)); + await Promise.all(stopPromises); } diff --git a/nodecar/src/proxy-worker.ts b/nodecar/src/proxy-worker.ts index 32bb7d7..4ccc295 100644 --- a/nodecar/src/proxy-worker.ts +++ b/nodecar/src/proxy-worker.ts @@ -1,5 +1,5 @@ import { Server } from "proxy-chain"; -import { getProxyConfig } from "./proxy-storage"; +import { getProxyConfig, updateProxyConfig } from "./proxy-storage"; /** * Run a proxy server as a worker process @@ -8,44 +8,68 @@ import { getProxyConfig } from "./proxy-storage"; export async function runProxyWorker(id: string): Promise { // Get the proxy configuration const config = getProxyConfig(id); - + if (!config) { console.error(`Proxy configuration ${id} not found`); process.exit(1); } - + // Create a new proxy server const server = new Server({ port: config.localPort, - host: "localhost", + host: "127.0.0.1", prepareRequestFunction: () => { return { upstreamProxyUrl: config.upstreamUrl, - ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate || false, + ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate ?? false, }; }, }); - - // Handle process termination - process.on("SIGTERM", async () => { - console.log(`Proxy worker ${id} received SIGTERM, shutting down...`); - await server.close(true); + + // Handle process termination gracefully + const gracefulShutdown = async (signal: string) => { + console.log(`Proxy worker ${id} received ${signal}, shutting down...`); + try { + await server.close(true); + console.log(`Proxy worker ${id} shut down successfully`); + } catch (error) { + console.error(`Error during shutdown for proxy ${id}:`, error); + } process.exit(0); + }; + + process.on("SIGTERM", () => void gracefulShutdown("SIGTERM")); + process.on("SIGINT", () => void gracefulShutdown("SIGINT")); + + // Handle uncaught exceptions + process.on("uncaughtException", (error) => { + console.error(`Uncaught exception in proxy worker ${id}:`, error); + process.exit(1); }); - - process.on("SIGINT", async () => { - console.log(`Proxy worker ${id} received SIGINT, shutting down...`); - await server.close(true); - process.exit(0); + + process.on("unhandledRejection", (reason) => { + console.error(`Unhandled rejection in proxy worker ${id}:`, reason); + process.exit(1); }); - + // Start the server try { await server.listen(); + + // Update the config with the actual port (in case it was auto-assigned) + config.localPort = server.port; + config.localUrl = `http://127.0.0.1:${server.port}`; + updateProxyConfig(config); + console.log(`Proxy worker ${id} started on port ${server.port}`); console.log(`Forwarding to upstream proxy: ${config.upstreamUrl}`); + + // Keep the process alive + setInterval(() => { + // Do nothing, just keep the process alive + }, 60000); } catch (error) { console.error(`Failed to start proxy worker ${id}:`, error); process.exit(1); } -} \ No newline at end of file +} diff --git a/nodecar/src/proxy.ts b/nodecar/src/proxy.ts deleted file mode 100644 index 13700c9..0000000 --- a/nodecar/src/proxy.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - startProxyProcess, - stopProxyProcess, - stopAllProxyProcesses, -} from "./proxy-runner"; -import { listProxyConfigs } from "./proxy-storage"; - -// Type definitions -interface ProxyOptions { - port?: number; - ignoreProxyCertificate?: boolean; - username?: string; - password?: string; -} - -/** - * Start a local proxy server that forwards to an upstream proxy - * @param upstreamProxyHost The upstream proxy host - * @param upstreamProxyPort The upstream proxy port - * @param upstreamProxyType The upstream proxy type (http, https, socks4, socks5) - * @param options Optional configuration including credentials - * @returns Promise resolving to the local proxy URL - */ -export async function startProxy( - upstreamProxyHost: string, - upstreamProxyPort: number, - upstreamProxyType: string, - options: ProxyOptions = {} -): Promise { - // Construct the upstream proxy URL with credentials if provided - let upstreamProxyUrl: string; - if (options.username && options.password) { - upstreamProxyUrl = `${upstreamProxyType}://${options.username}:${options.password}@${upstreamProxyHost}:${upstreamProxyPort}`; - } else { - upstreamProxyUrl = `${upstreamProxyType}://${upstreamProxyHost}:${upstreamProxyPort}`; - } - - const config = await startProxyProcess(upstreamProxyUrl, { - port: options.port, - ignoreProxyCertificate: options.ignoreProxyCertificate, - }); - - return config.localUrl || `http://localhost:${config.localPort}`; -} - -/** - * Stop a specific proxy by its upstream host, port, and type - * @param upstreamProxyHost The upstream proxy host - * @param upstreamProxyPort The upstream proxy port - * @param upstreamProxyType The upstream proxy type - * @returns Promise resolving to true if proxy was found and stopped, false otherwise - */ -export async function stopProxy( - upstreamProxyHost: string, - upstreamProxyPort: number, - upstreamProxyType: string -): Promise { - // Find all proxies with matching upstream details (ignoring credentials in URL) - const configs = listProxyConfigs().filter((config) => { - // Parse the upstream URL to extract host, port, and type - try { - const url = new URL(config.upstreamUrl); - return ( - url.hostname === upstreamProxyHost && - Number.parseInt(url.port) === upstreamProxyPort && - url.protocol.replace(":", "") === upstreamProxyType - ); - } catch { - return false; - } - }); - - if (configs.length === 0) { - return false; - } - - // Stop all matching proxies - let success = true; - for (const config of configs) { - const stopped = await stopProxyProcess(config.id); - if (!stopped) { - success = false; - } - } - - return success; -} - -/** - * Get a list of all active proxy upstream URLs - * @returns Array of upstream proxy URLs - */ -export function getActiveProxies(): string[] { - return listProxyConfigs().map((config) => config.upstreamUrl); -} - -/** - * Stop all active proxies - * @returns Promise that resolves when all proxies are stopped - */ -export async function stopAllProxies(): Promise { - await stopAllProxyProcesses(); -} diff --git a/package.json b/package.json index c93dfd9..65c5881 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ }, "packageManager": "pnpm@10.11.1", "lint-staged": { - "src/**/*.{js,jsx,ts,tsx,json,css,md}": [ + "**/*.{js,jsx,ts,tsx,json,css,md}": [ "biome check --fix", "eslint --cache --fix" ], diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 55f434a..8ad5170 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -6,6 +6,8 @@ Donut Browser needs camera access to enable camera functionality in web browsers. Each website will still ask for your permission individually. NSMicrophoneUsageDescription Donut Browser needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually. + NSLocalNetworkUsageDescription + Donut Browser has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles. CFBundleDisplayName Donut Browser CFBundleName diff --git a/src-tauri/assets/template.pac b/src-tauri/assets/template.pac index 6476447..cb61a8a 100644 --- a/src-tauri/assets/template.pac +++ b/src-tauri/assets/template.pac @@ -1,14 +1,3 @@ function FindProxyForURL(url, host) { - const proxyString = "{{proxy_url}}"; - - // Split the proxy string to get the credentials part - const parts = proxyString.split(" ")[1].split("@"); - if (parts.length > 1) { - const credentials = parts[0]; - const encodedCredentials = encodeURIComponent(credentials); - // Replace the original credentials with encoded ones - return proxyString.replace(credentials, encodedCredentials); - } - - return proxyString; + return "{{proxy_url}}"; } diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index d4b95f8..47aaca5 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -47,7 +47,7 @@ impl ProxyManager { return Ok(ProxySettings { enabled: true, proxy_type: proxy.upstream_type.clone(), - host: "localhost".to_string(), + host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility port: proxy.local_port, username: None, password: None, @@ -74,11 +74,11 @@ impl ProxyManager { None }; - // Start a new proxy using the nodecar binary + // Start a new proxy using the nodecar binary with the correct CLI interface let mut nodecar = app_handle .shell() .sidecar("nodecar") - .unwrap() + .map_err(|e| format!("Failed to create sidecar: {e}"))? .arg("proxy") .arg("start") .arg("--host") @@ -101,11 +101,19 @@ impl ProxyManager { nodecar = nodecar.arg("--port").arg(port.to_string()); } - let output = nodecar.output().await.unwrap(); + // Execute the command and wait for it to complete + // The nodecar binary should start the worker and then exit + let output = nodecar + .output() + .await + .map_err(|e| format!("Failed to execute nodecar: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Proxy start failed: {stderr}")); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(format!( + "Proxy start failed - stdout: {stdout}, stderr: {stderr}" + )); } let json_string = @@ -148,7 +156,7 @@ impl ProxyManager { Ok(ProxySettings { enabled: true, proxy_type: "http".to_string(), - host: "localhost".to_string(), + host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility port: proxy_info.local_port, username: None, password: None, @@ -196,7 +204,7 @@ impl ProxyManager { proxies.get(&browser_pid).map(|proxy| ProxySettings { enabled: true, proxy_type: "http".to_string(), - host: "localhost".to_string(), + host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility port: proxy.local_port, username: None, password: None, @@ -355,7 +363,7 @@ mod tests { assert!(proxy_settings.is_some()); let settings = proxy_settings.unwrap(); assert!(settings.enabled); - assert_eq!(settings.host, "localhost"); + assert_eq!(settings.host, "127.0.0.1"); assert_eq!(settings.port, 8080); // Test non-existent browser PID @@ -407,7 +415,7 @@ mod tests { let browser_pid = (1000 + i) as u32; let proxy_info = ProxyInfo { id: format!("proxy_{i}"), - local_url: format!("http://localhost:{}", 8000 + i), + local_url: format!("http://127.0.0.1:{}", 8000 + i), upstream_host: "127.0.0.1".to_string(), upstream_port: 3128, upstream_type: "http".to_string(), @@ -454,21 +462,19 @@ mod tests { let upstream_addr = upstream_listener.local_addr()?; // Spawn upstream server - tokio::spawn(async move { - loop { - if let Ok((stream, _)) = upstream_listener.accept().await { - let io = TokioIo::new(stream); - tokio::task::spawn(async move { - let _ = http1::Builder::new() - .serve_connection( - io, - service_fn(|_req| async { - Ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from("Upstream OK")))) - }), - ) - .await; - }); - } + let server_handle = tokio::spawn(async move { + while let Ok((stream, _)) = upstream_listener.accept().await { + let io = TokioIo::new(stream); + tokio::task::spawn(async move { + let _ = http1::Builder::new() + .serve_connection( + io, + service_fn(|_req| async { + Ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from("Upstream OK")))) + }), + ) + .await; + }); } }); @@ -487,7 +493,8 @@ mod tests { .arg("--type") .arg("http"); - let output = cmd.output()?; + // Set a timeout for the command + let output = tokio::time::timeout(Duration::from_secs(10), async { cmd.output() }).await??; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; @@ -499,12 +506,33 @@ mod tests { assert!(config["localUrl"].is_string()); let proxy_id = config["id"].as_str().unwrap(); + let local_port = config["localPort"].as_u64().unwrap(); + + // Wait for proxy worker to start + println!("Waiting for proxy worker to start..."); + tokio::time::sleep(Duration::from_secs(3)).await; + + // Test that the local port is listening + let mut port_test = Command::new("nc"); + port_test + .arg("-z") + .arg("127.0.0.1") + .arg(local_port.to_string()); + + let port_output = port_test.output()?; + if port_output.status.success() { + println!("Proxy is listening on port {local_port}"); + } else { + println!("Warning: Proxy port {local_port} is not listening"); + } // Test stopping the proxy let mut stop_cmd = Command::new(&nodecar_path); stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id); - let stop_output = stop_cmd.output()?; + let stop_output = + tokio::time::timeout(Duration::from_secs(5), async { stop_cmd.output() }).await??; + assert!(stop_output.status.success()); println!("Integration test passed: nodecar proxy start/stop works correctly"); @@ -514,6 +542,9 @@ mod tests { return Err(format!("Nodecar command failed: {stderr}").into()); } + // Clean up server + server_handle.abort(); + Ok(()) } @@ -552,4 +583,166 @@ mod tests { assert_eq!(proxy_settings.username.as_ref().unwrap(), "user"); assert_eq!(proxy_settings.password.as_ref().unwrap(), "pass"); } + + // Test the CLI detachment specifically - ensure the CLI exits properly + #[tokio::test] + async fn test_cli_exits_after_proxy_start() -> Result<(), Box> { + let nodecar_path = ensure_nodecar_binary().await?; + + // Test that the CLI exits quickly with a mock upstream + let mut cmd = Command::new(&nodecar_path); + cmd + .arg("proxy") + .arg("start") + .arg("--host") + .arg("httpbin.org") + .arg("--proxy-port") + .arg("80") + .arg("--type") + .arg("http"); + + let start_time = std::time::Instant::now(); + let output = tokio::time::timeout(Duration::from_secs(3), async { cmd.output() }).await; + + match output { + Ok(Ok(cmd_output)) => { + let execution_time = start_time.elapsed(); + println!("CLI completed in {execution_time:?}"); + + // Should complete very quickly if properly detached + assert!( + execution_time < Duration::from_secs(3), + "CLI took too long ({execution_time:?}), should exit immediately after starting worker" + ); + + if cmd_output.status.success() { + let stdout = String::from_utf8(cmd_output.stdout)?; + let config: serde_json::Value = serde_json::from_str(&stdout)?; + + // Clean up - try to stop the proxy + if let Some(proxy_id) = config["id"].as_str() { + let mut stop_cmd = Command::new(&nodecar_path); + stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id); + let _ = stop_cmd.output(); + } + } + + println!("CLI detachment test passed - CLI exited in {execution_time:?}"); + } + Ok(Err(e)) => { + return Err(format!("Command execution failed: {e}").into()); + } + Err(_) => { + return Err("CLI command timed out - this indicates improper detachment".into()); + } + } + + Ok(()) + } + + // Test that validates proper CLI detachment behavior + #[tokio::test] + async fn test_cli_detachment_behavior() -> Result<(), Box> { + let nodecar_path = ensure_nodecar_binary().await?; + + // Test that the CLI command exits quickly even with a real upstream + let mut cmd = Command::new(&nodecar_path); + cmd + .arg("proxy") + .arg("start") + .arg("--host") + .arg("httpbin.org") // Use a known good endpoint + .arg("--proxy-port") + .arg("80") + .arg("--type") + .arg("http"); + + let start_time = std::time::Instant::now(); + let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??; + let execution_time = start_time.elapsed(); + + // Command should complete very quickly if properly detached + assert!( + execution_time < Duration::from_secs(5), + "CLI command took {execution_time:?}, should complete in under 5 seconds for proper detachment" + ); + + println!("CLI detachment test: command completed in {execution_time:?}"); + + if output.status.success() { + let stdout = String::from_utf8(output.stdout)?; + let config: serde_json::Value = serde_json::from_str(&stdout)?; + let proxy_id = config["id"].as_str().unwrap(); + + // Clean up + let mut stop_cmd = Command::new(&nodecar_path); + stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id); + let _ = stop_cmd.output(); + + println!("CLI detachment test passed"); + } else { + // Even if the upstream fails, the CLI should still exit quickly + println!("CLI command failed but exited quickly as expected"); + } + + Ok(()) + } + + // Test that validates URL encoding for special characters in credentials + #[tokio::test] + async fn test_proxy_credentials_encoding() -> Result<(), Box> { + let nodecar_path = ensure_nodecar_binary().await?; + + // Test with credentials that include special characters + let mut cmd = Command::new(&nodecar_path); + cmd + .arg("proxy") + .arg("start") + .arg("--host") + .arg("test.example.com") + .arg("--proxy-port") + .arg("8080") + .arg("--type") + .arg("http") + .arg("--username") + .arg("user@domain.com") // Contains @ symbol + .arg("--password") + .arg("pass word!"); // Contains space and special character + + let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??; + + if output.status.success() { + let stdout = String::from_utf8(output.stdout)?; + let config: serde_json::Value = serde_json::from_str(&stdout)?; + + let upstream_url = config["upstreamUrl"].as_str().unwrap(); + + println!("Generated upstream URL: {upstream_url}"); + + // Verify that special characters are properly encoded + assert!(upstream_url.contains("user%40domain.com")); + // The password may be encoded as "pass%20word!" or "pass%20word%21" depending on implementation + assert!(upstream_url.contains("pass%20word")); + + println!("URL encoding test passed - special characters handled correctly"); + + // Clean up + let proxy_id = config["id"].as_str().unwrap(); + let mut stop_cmd = Command::new(&nodecar_path); + stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id); + let _ = stop_cmd.output(); + } else { + // This test might fail if the upstream doesn't exist, but we mainly care about URL construction + let stdout = String::from_utf8(output.stdout)?; + let stderr = String::from_utf8(output.stderr)?; + println!("Command failed (expected for non-existent upstream):"); + println!("Stdout: {stdout}"); + println!("Stderr: {stderr}"); + + // The important thing is that the command completed quickly + println!("URL encoding test completed - credentials should be properly encoded"); + } + + Ok(()) + } } diff --git a/src/components/window-drag-area.tsx b/src/components/window-drag-area.tsx index fef1cbe..44daf51 100644 --- a/src/components/window-drag-area.tsx +++ b/src/components/window-drag-area.tsx @@ -40,7 +40,7 @@ export function WindowDragArea() { return (