Compare commits

...

31 Commits

Author SHA1 Message Date
zhom 0047c80967 style: make the row chart shorter 2025-11-30 21:28:19 +04:00
zhom 3d7bd2b14c chore: version bump 2025-11-30 21:25:25 +04:00
zhom 8899e58987 chore: simplify tsconfig 2025-11-30 21:18:56 +04:00
zhom acf8651bd1 refactor: fix types after dependency upgrade 2025-11-30 21:16:26 +04:00
zhom ef534ee779 chore: update major dependencies 2025-11-30 21:06:09 +04:00
zhom 75bb10cf61 chore: remove ipecho from domain checkers 2025-11-30 21:03:31 +04:00
zhom 6f9e0de633 chore: update dependencies 2025-11-30 20:59:19 +04:00
zhom 39c2a9f6f0 refactor: disable quit confirmations in browser 2025-11-30 20:59:04 +04:00
zhom 4b6f08fca3 refactor: disable more update-related settings 2025-11-30 20:44:59 +04:00
zhom 24eff75d4e chore: cleanup logs 2025-11-30 20:42:06 +04:00
zhom 11869855e9 build: make permissions more explicit 2025-11-30 20:40:34 +04:00
zhom 0d1f1f1497 refactor: suppress first-run warnings 2025-11-30 20:40:10 +04:00
zhom e8026d817f refactor: clean up old binary after installation 2025-11-30 20:39:34 +04:00
zhom d1ca4273de chore: check tag name instead of ref 2025-11-30 20:08:25 +04:00
zhom e8c382400c chore: version bump 2025-11-30 17:36:31 +04:00
zhom c40f023d41 refactor: improved performance for old profiles 2025-11-30 17:34:04 +04:00
zhom e16512576c refactor: try getting high priority for local proxies 2025-11-30 17:04:07 +04:00
zhom f098128988 refactor: cleanup bandwidth tracking functionality 2025-11-30 16:55:23 +04:00
zhom cdba9aac33 feat: add network overview 2025-11-30 15:04:48 +04:00
zhom 01b3109dc1 feat: add mass-proxy-assign action 2025-11-30 11:03:19 +04:00
zhom 8aa3885240 style: move the body of the profile creation trigger a bit left 2025-11-30 10:46:03 +04:00
zhom 5947ec14e6 feat: add notes 2025-11-30 10:45:39 +04:00
zhom 2c7c07c414 test: fix flackyness 2025-11-30 09:51:28 +04:00
zhom 2e26b53db8 refactor: reuse copy-to-clipboard button 2025-11-30 00:31:42 +04:00
zhom 966a10c045 feat: allow user select system for which to generate fingerprint 2025-11-30 00:27:08 +04:00
zhom f72e3066f3 feat: add catppuccin themes 2025-11-30 00:14:23 +04:00
zhom cd8e1dcf18 refactor: allow the user to view the fingerprint while the profile is running 2025-11-29 23:50:54 +04:00
zhom dfc8cd4c9f chore: cleanup windows config 2025-11-29 23:40:33 +04:00
zhom 5a1726d119 build: disable msi bundler until ice30 issue is resolved 2025-11-29 23:38:54 +04:00
zhom 133ed98df1 chore: update windows config 2025-11-29 23:37:46 +04:00
zhom 4683410a2c feat: add openapi spec generation 2025-11-29 23:32:49 +04:00
49 changed files with 5580 additions and 1238 deletions
@@ -11,7 +11,7 @@ permissions:
jobs:
generate-release-notes:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') && !github.event.release.prerelease
if: startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease
steps:
- name: Checkout repository
@@ -23,7 +23,7 @@ jobs:
id: get-previous-tag
run: |
# Get the previous release tag (excluding the current one)
CURRENT_TAG="${{ github.ref_name }}"
CURRENT_TAG="${{ github.event.release.tag_name }}"
PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "$CURRENT_TAG" | head -n 1)
if [ -z "$PREVIOUS_TAG" ]; then
+12
View File
@@ -17,6 +17,7 @@
"busctl",
"CAMOU",
"camoufox",
"catppuccin",
"cdylib",
"certifi",
"CFURL",
@@ -53,6 +54,7 @@
"esac",
"esbuild",
"etree",
"firstrun",
"flate",
"frontmost",
"geoip",
@@ -69,6 +71,7 @@
"idlelib",
"idletime",
"idna",
"infobars",
"Inno",
"kdeglobals",
"keras",
@@ -76,10 +79,12 @@
"killall",
"Kolkata",
"kreadconfig",
"langpack",
"launchservices",
"letterboxing",
"libatk",
"libayatana",
"libc",
"libcairo",
"libgdk",
"libglib",
@@ -91,6 +96,7 @@
"lpdw",
"lxml",
"lzma",
"macchiato",
"Matchalk",
"mmdb",
"mountpoint",
@@ -126,6 +132,7 @@
"plasmohq",
"platformdirs",
"prefs",
"PRIO",
"propertylist",
"psutil",
"pycache",
@@ -135,6 +142,7 @@
"pyoxidizer",
"pytest",
"pyyaml",
"reportingpolicy",
"reqwest",
"ridedott",
"rlib",
@@ -145,6 +153,9 @@
"screeninfo",
"selectables",
"serde",
"sessionstore",
"setpriority",
"setsid",
"SETTINGCHANGE",
"setuptools",
"shadcn",
@@ -178,6 +189,7 @@
"Torbrowser",
"tqdm",
"trackingprotection",
"trailhead",
"turbopack",
"turtledemo",
"udeps",
+4 -4
View File
@@ -21,15 +21,15 @@
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^24.10.0",
"@types/node": "^24.10.1",
"commander": "^14.0.2",
"donutbrowser-camoufox-js": "^0.7.0",
"dotenv": "^17.2.3",
"fingerprint-generator": "^2.1.76",
"fingerprint-generator": "^2.1.77",
"get-port": "^7.1.0",
"nodemon": "^3.1.11",
"playwright-core": "^1.56.1",
"proxy-chain": "^2.5.9",
"playwright-core": "^1.57.0",
"proxy-chain": "^2.6.0",
"tmp": "^0.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
+85 -27
View File
@@ -267,6 +267,18 @@ export async function startCamoufoxProcess(
});
}
/**
* Check if a process is running by PID
*/
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Stop a Camoufox process
* @param id The Camoufox ID to stop
@@ -279,45 +291,85 @@ export async function stopCamoufoxProcess(id: string): Promise<boolean> {
return false;
}
const pid = config.processId;
try {
// Method 1: If we have a process ID, kill by PID with proper signal sequence
if (config.processId) {
if (pid && isProcessRunning(pid)) {
try {
// First try SIGTERM for graceful shutdown
process.kill(config.processId, "SIGTERM");
// Give it more time to terminate gracefully (increased from 2s to 5s)
await new Promise((resolve) => setTimeout(resolve, 5000));
process.kill(pid, "SIGTERM");
// Check if process is still running
try {
process.kill(config.processId, 0); // Signal 0 checks if process exists
process.kill(config.processId, "SIGKILL");
} catch {}
} catch {}
// Wait up to 3 seconds for graceful shutdown
for (let i = 0; i < 30; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (!isProcessRunning(pid)) {
break;
}
}
// If still running, force kill
if (isProcessRunning(pid)) {
process.kill(pid, "SIGKILL");
// Wait for SIGKILL to take effect
for (let i = 0; i < 20; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (!isProcessRunning(pid)) {
break;
}
}
}
} catch {
// Process might have already exited
}
}
// Method 2: Pattern-based kill as fallback
const killByPattern = spawn(
"pkill",
["-TERM", "-f", `camoufox-worker.*${id}`],
{
stdio: "ignore",
},
);
// Wait for pattern-based kill command to complete
// Method 2: Pattern-based kill as fallback (kills any child processes)
await new Promise<void>((resolve) => {
const killByPattern = spawn(
"pkill",
["-TERM", "-f", `camoufox-worker.*${id}`],
{ stdio: "ignore" },
);
killByPattern.on("exit", () => resolve());
// Timeout after 3 seconds
setTimeout(() => resolve(), 3000);
setTimeout(() => resolve(), 1000);
});
// Final cleanup with SIGKILL if needed
setTimeout(() => {
spawn("pkill", ["-KILL", "-f", `camoufox-worker.*${id}`], {
stdio: "ignore",
// Wait a moment then force kill any remaining
await new Promise((resolve) => setTimeout(resolve, 500));
await new Promise<void>((resolve) => {
const killByPatternForce = spawn(
"pkill",
["-KILL", "-f", `camoufox-worker.*${id}`],
{ stdio: "ignore" },
);
killByPatternForce.on("exit", () => resolve());
setTimeout(() => resolve(), 1000);
});
// Also kill any Firefox processes associated with this profile
if (config.profilePath) {
await new Promise<void>((resolve) => {
const killFirefox = spawn(
"pkill",
["-KILL", "-f", config.profilePath!],
{ stdio: "ignore" },
);
killFirefox.on("exit", () => resolve());
setTimeout(() => resolve(), 1000);
});
}, 1000);
}
// Verify process is actually dead
if (pid && isProcessRunning(pid)) {
// Last resort: SIGKILL again
try {
process.kill(pid, "SIGKILL");
} catch {
// Ignore
}
}
// Delete the configuration
deleteCamoufoxConfig(id);
@@ -352,6 +404,7 @@ interface GenerateConfigOptions {
blockWebgl?: boolean;
executablePath?: string;
fingerprint?: string;
os?: "windows" | "macos" | "linux";
}
/**
@@ -433,6 +486,11 @@ export async function generateCamoufoxConfig(
launchOpts.allowAddonNewTab = true;
// Add OS option for fingerprint generation
if (options.os) {
launchOpts.os = options.os;
}
// Generate the configuration using launchOptions
const generatedOptions = await launchOptions(launchOpts);
+8
View File
@@ -34,6 +34,10 @@ program
.option("--fingerprint <json>", "fingerprint JSON string")
.option("--headless", "run in headless mode")
.option("--custom-config <json>", "custom config JSON string")
.option(
"--os <os>",
"operating system for fingerprint: windows, macos, linux",
)
.description("manage Camoufox browser instances")
.action(
@@ -284,6 +288,10 @@ program
typeof options.fingerprint === "string"
? options.fingerprint
: undefined,
os:
typeof options.os === "string"
? (options.os as "windows" | "macos" | "linux")
: undefined,
});
console.log(config);
process.exit(0);
+1
View File
@@ -66,6 +66,7 @@ export function parseProxyString(proxyString: LaunchOptions["proxy"] | string) {
// Try parsing as URL first (handles protocol://username:password@host:port)
if (trimmed.includes("://")) {
const url = new URL(trimmed);
// Playwright accepts short form "host:port" for HTTP proxies
server = `${url.hostname}:${url.port}`;
if (url.username) {
+12 -10
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.12.3",
"version": "0.13.1",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
@@ -41,7 +41,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-deep-link": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "~2.4.4",
@@ -51,15 +51,17 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.2",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"lucide-react": "^0.555.0",
"motion": "^12.23.24",
"next": "^15.5.6",
"next": "^16.0.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"recharts": "3.5.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
@@ -67,14 +69,14 @@
"devDependencies": {
"@biomejs/biome": "2.2.3",
"@tailwindcss/postcss": "^4.1.17",
"@tauri-apps/cli": "^2.9.4",
"@tauri-apps/cli": "^2.9.5",
"@types/color": "^4.2.0",
"@types/node": "^24.10.0",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"husky": "^9.1.7",
"lint-staged": "^16.2.6",
"lint-staged": "^16.2.7",
"tailwindcss": "^4.1.17",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
+1165 -855
View File
File diff suppressed because it is too large Load Diff
+47 -2
View File
@@ -1293,7 +1293,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.12.3"
version = "0.13.1"
dependencies = [
"aes-gcm",
"argon2",
@@ -1341,6 +1341,8 @@ dependencies = [
"tower-http",
"url",
"urlencoding",
"utoipa",
"utoipa-axum",
"uuid",
"windows 0.62.2",
"winreg",
@@ -2281,7 +2283,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
"windows-core 0.62.2",
]
[[package]]
@@ -3483,6 +3485,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -5973,6 +5981,43 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
dependencies = [
"indexmap 2.12.0",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-axum"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0"
dependencies = [
"axum",
"paste",
"tower-layer",
"tower-service",
"utoipa",
]
[[package]]
name = "utoipa-gen"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.110",
]
[[package]]
name = "uuid"
version = "1.18.1"
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.12.3"
version = "0.13.1"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -64,6 +64,8 @@ axum = "0.8.7"
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
rand = "0.9.2"
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2"
argon2 = "0.5"
aes-gcm = "0.10"
hyper = { version = "1.8", features = ["full"] }
+9 -1
View File
@@ -5,7 +5,15 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-emit",
"core:event:allow-emit-to",
"core:event:allow-unlisten",
"core:image:default",
"core:menu:default",
"core:path:default",
"core:tray:default",
"core:webview:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
-1
View File
@@ -564,7 +564,6 @@ impl ApiClient {
let cached_data: CachedGithubData = serde_json::from_str(&content).ok()?;
// Always use cached GitHub releases - cache never expires, only gets updated with new versions
log::info!("Using cached GitHub releases for {browser}");
Some(cached_data.releases)
}
+511 -87
View File
@@ -1,3 +1,4 @@
use crate::browser::ProxySettings;
use crate::camoufox_manager::CamoufoxConfig;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::manager::ProfileManager;
@@ -8,7 +9,7 @@ use axum::{
http::{HeaderMap, StatusCode},
middleware::{self, Next},
response::{Json, Response},
routing::{delete, get, post, put},
routing::get,
Router,
};
use lazy_static::lazy_static;
@@ -18,9 +19,11 @@ use tauri::Emitter;
use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex};
use tower_http::cors::CorsLayer;
use utoipa::{OpenApi, ToSchema};
use utoipa_axum::{router::OpenApiRouter, routes};
// API Types
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct ApiProfile {
pub id: String,
pub name: String,
@@ -30,42 +33,45 @@ pub struct ApiProfile {
pub process_id: Option<u32>,
pub last_launch: Option<u64>,
pub release_type: String,
#[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Vec<String>,
pub is_running: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiProfilesResponse {
pub profiles: Vec<ApiProfile>,
pub total: usize,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiProfileResponse {
pub profile: ApiProfile,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateProfileRequest {
pub name: String,
pub browser: String,
pub version: String,
pub proxy_id: Option<String>,
pub release_type: Option<String>,
#[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateProfileRequest {
pub name: Option<String>,
pub browser: Option<String>,
pub version: Option<String>,
pub proxy_id: Option<String>,
pub release_type: Option<String>,
#[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Option<Vec<String>>,
@@ -76,56 +82,59 @@ struct ApiServerState {
app_handle: tauri::AppHandle,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiGroupResponse {
id: String,
name: String,
profile_count: usize,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct CreateGroupRequest {
name: String,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct UpdateGroupRequest {
name: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiProxyResponse {
id: String,
name: String,
proxy_settings: serde_json::Value,
#[schema(value_type = Object)]
proxy_settings: ProxySettings,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct CreateProxyRequest {
name: String,
proxy_settings: serde_json::Value,
#[schema(value_type = Object)]
proxy_settings: ProxySettings,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct UpdateProxyRequest {
name: Option<String>,
proxy_settings: Option<serde_json::Value>,
#[schema(value_type = Object)]
proxy_settings: Option<ProxySettings>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct DownloadBrowserRequest {
browser: String,
version: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
struct DownloadBrowserResponse {
browser: String,
version: String,
status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ToastPayload {
pub message: String,
pub variant: String,
@@ -133,24 +142,98 @@ pub struct ToastPayload {
pub description: Option<String>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
struct RunProfileResponse {
profile_id: String,
remote_debugging_port: u16,
headless: bool,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct RunProfileRequest {
url: Option<String>,
headless: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct OpenUrlRequest {
url: String,
}
#[derive(OpenApi)]
#[openapi(
paths(
get_profiles,
get_profile,
create_profile,
update_profile,
delete_profile,
run_profile,
open_url_in_profile,
kill_profile,
get_groups,
get_group,
create_group,
update_group,
delete_group,
get_tags,
get_proxies,
get_proxy,
create_proxy,
update_proxy,
delete_proxy,
download_browser_api,
get_browser_versions,
check_browser_downloaded,
),
components(schemas(
ApiProfile,
ApiProfilesResponse,
ApiProfileResponse,
CreateProfileRequest,
UpdateProfileRequest,
ApiGroupResponse,
CreateGroupRequest,
UpdateGroupRequest,
ApiProxyResponse,
CreateProxyRequest,
UpdateProxyRequest,
DownloadBrowserRequest,
DownloadBrowserResponse,
RunProfileResponse,
RunProfileRequest,
OpenUrlRequest,
ProxySettings,
)),
tags(
(name = "profiles", description = "Profile management endpoints"),
(name = "groups", description = "Group management endpoints"),
(name = "tags", description = "Tag management endpoints"),
(name = "proxies", description = "Proxy management endpoints"),
(name = "browsers", description = "Browser management endpoints"),
),
modifiers(&SecurityAddon),
)]
struct ApiDoc;
struct SecurityAddon;
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"bearer_auth",
utoipa::openapi::security::SecurityScheme::Http(
utoipa::openapi::security::HttpBuilder::new()
.scheme(utoipa::openapi::security::HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
}
pub struct ApiServer {
port: Option<u16>,
shutdown_tx: Option<mpsc::Sender<()>>,
@@ -207,40 +290,44 @@ impl ApiServer {
.map_err(|e| format!("Failed to get local address: {e}"))?
.port();
// Create router with CORS, authentication, and versioning
let v1_routes = Router::new()
.route("/profiles", get(get_profiles))
.route("/profiles", post(create_profile))
.route("/profiles/{id}", get(get_profile))
.route("/profiles/{id}", put(update_profile))
.route("/profiles/{id}", delete(delete_profile))
.route("/profiles/{id}/run", post(run_profile))
.route("/profiles/{id}/open-url", post(open_url_in_profile))
.route("/profiles/{id}/kill", post(kill_profile))
.route("/groups", get(get_groups).post(create_group))
.route(
"/groups/{id}",
get(get_group).put(update_group).delete(delete_group),
)
.route("/tags", get(get_tags))
.route("/proxies", get(get_proxies).post(create_proxy))
.route(
"/proxies/{id}",
get(get_proxy).put(update_proxy).delete(delete_proxy),
)
.route("/browsers/download", post(download_browser_api))
.route("/browsers/{browser}/versions", get(get_browser_versions))
.route(
"/browsers/{browser}/versions/{version}/downloaded",
get(check_browser_downloaded),
)
.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
// Create router with OpenAPI documentation
let (v1_routes, _) = OpenApiRouter::new()
.routes(routes!(
get_profiles,
create_profile,
get_profile,
update_profile,
delete_profile,
run_profile,
open_url_in_profile,
kill_profile,
get_groups,
create_group,
get_group,
update_group,
delete_group,
get_tags,
get_proxies,
create_proxy,
get_proxy,
update_proxy,
delete_proxy,
download_browser_api,
get_browser_versions,
check_browser_downloaded,
))
.split_for_parts();
let api = ApiDoc::openapi();
let v1_routes = v1_routes.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
let app = Router::new()
.nest("/v1", v1_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
.layer(CorsLayer::permissive())
.with_state(state);
@@ -346,6 +433,19 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
}
// API Handlers - Profiles
#[utoipa::path(
get,
path = "/v1/profiles",
responses(
(status = 200, description = "List of all profiles", body = ApiProfilesResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
match profile_manager.list_profiles() {
@@ -380,6 +480,23 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
}
}
#[utoipa::path(
get,
path = "/v1/profiles/{id}",
params(
("id" = String, Path, description = "Profile ID")
),
responses(
(status = 200, description = "Profile details", body = ApiProfileResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn get_profile(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
@@ -415,6 +532,21 @@ async fn get_profile(
}
}
#[utoipa::path(
post,
path = "/v1/profiles",
request_body = CreateProfileRequest,
responses(
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn create_profile(
State(state): State<ApiServerState>,
Json(request): Json<CreateProfileRequest>,
@@ -485,6 +617,25 @@ async fn create_profile(
}
}
#[utoipa::path(
put,
path = "/v1/profiles/{id}",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = UpdateProfileRequest,
responses(
(status = 200, description = "Profile updated successfully", body = ApiProfileResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn update_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -566,6 +717,23 @@ async fn update_profile(
get_profile(Path(id), State(state)).await
}
#[utoipa::path(
delete,
path = "/v1/profiles/{id}",
params(
("id" = String, Path, description = "Profile ID")
),
responses(
(status = 204, description = "Profile deleted successfully"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn delete_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -578,6 +746,19 @@ async fn delete_profile(
}
// API Handlers - Groups
#[utoipa::path(
get,
path = "/v1/groups",
responses(
(status = 200, description = "List of all groups", body = Vec<ApiGroupResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "groups"
)]
async fn get_groups(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<ApiGroupResponse>>, StatusCode> {
@@ -602,6 +783,23 @@ async fn get_groups(
}
}
#[utoipa::path(
get,
path = "/v1/groups/{id}",
params(
("id" = String, Path, description = "Group ID")
),
responses(
(status = 200, description = "Group details", body = ApiGroupResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Group not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "groups"
)]
async fn get_group(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
@@ -625,6 +823,21 @@ async fn get_group(
}
}
#[utoipa::path(
post,
path = "/v1/groups",
request_body = CreateGroupRequest,
responses(
(status = 200, description = "Group created successfully", body = ApiGroupResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "groups"
)]
async fn create_group(
State(state): State<ApiServerState>,
Json(request): Json<CreateGroupRequest>,
@@ -642,6 +855,25 @@ async fn create_group(
}
}
#[utoipa::path(
put,
path = "/v1/groups/{id}",
params(
("id" = String, Path, description = "Group ID")
),
request_body = UpdateGroupRequest,
responses(
(status = 200, description = "Group updated successfully", body = ApiGroupResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Group not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "groups"
)]
async fn update_group(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -660,6 +892,23 @@ async fn update_group(
}
}
#[utoipa::path(
delete,
path = "/v1/groups/{id}",
params(
("id" = String, Path, description = "Group ID")
),
responses(
(status = 204, description = "Group deleted successfully"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "groups"
)]
async fn delete_group(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -674,6 +923,19 @@ async fn delete_group(
}
// API Handlers - Tags
#[utoipa::path(
get,
path = "/v1/tags",
responses(
(status = 200, description = "List of all tags", body = Vec<String>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "tags"
)]
async fn get_tags(State(_state): State<ApiServerState>) -> Result<Json<Vec<String>>, StatusCode> {
match TAG_MANAGER.lock() {
Ok(manager) => match manager.get_all_tags() {
@@ -685,6 +947,19 @@ async fn get_tags(State(_state): State<ApiServerState>) -> Result<Json<Vec<Strin
}
// API Handlers - Proxies
#[utoipa::path(
get,
path = "/v1/proxies",
responses(
(status = 200, description = "List of all proxies", body = Vec<ApiProxyResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "proxies"
)]
async fn get_proxies(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<ApiProxyResponse>>, StatusCode> {
@@ -695,12 +970,29 @@ async fn get_proxies(
.map(|p| ApiProxyResponse {
id: p.id,
name: p.name,
proxy_settings: serde_json::to_value(p.proxy_settings).unwrap_or_default(),
proxy_settings: p.proxy_settings,
})
.collect(),
))
}
#[utoipa::path(
get,
path = "/v1/proxies/{id}",
params(
("id" = String, Path, description = "Proxy ID")
),
responses(
(status = 200, description = "Proxy details", body = ApiProxyResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Proxy not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "proxies"
)]
async fn get_proxy(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
@@ -710,45 +1002,65 @@ async fn get_proxy(
Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
proxy_settings: serde_json::to_value(proxy.proxy_settings).unwrap_or_default(),
proxy_settings: proxy.proxy_settings,
}))
} else {
Err(StatusCode::NOT_FOUND)
}
}
#[utoipa::path(
post,
path = "/v1/proxies",
request_body = CreateProxyRequest,
responses(
(status = 200, description = "Proxy created successfully", body = ApiProxyResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "proxies"
)]
async fn create_proxy(
State(state): State<ApiServerState>,
Json(request): Json<CreateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
// Convert JSON value to ProxySettings
match serde_json::from_value(request.proxy_settings.clone()) {
Ok(proxy_settings) => {
match PROXY_MANAGER.create_stored_proxy(
&state.app_handle,
request.name.clone(),
proxy_settings,
) {
Ok(_) => {
// Find the created proxy to return it
let proxies = PROXY_MANAGER.get_stored_proxies();
if let Some(proxy) = proxies.into_iter().find(|p| p.name == request.name) {
Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
proxy_settings: request.proxy_settings,
}))
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
match PROXY_MANAGER.create_stored_proxy(
&state.app_handle,
request.name.clone(),
request.proxy_settings,
) {
Ok(proxy) => Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
proxy_settings: proxy.proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
#[utoipa::path(
put,
path = "/v1/proxies/{id}",
params(
("id" = String, Path, description = "Proxy ID")
),
request_body = UpdateProxyRequest,
responses(
(status = 200, description = "Proxy updated successfully", body = ApiProxyResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Proxy not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "proxies"
)]
async fn update_proxy(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -757,14 +1069,9 @@ async fn update_proxy(
let proxies = PROXY_MANAGER.get_stored_proxies();
if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
let new_name = request.name.unwrap_or(proxy.name.clone());
let new_proxy_settings = if let Some(settings_json) = request.proxy_settings {
match serde_json::from_value(settings_json) {
Ok(settings) => settings,
Err(_) => return Err(StatusCode::BAD_REQUEST),
}
} else {
proxy.proxy_settings.clone()
};
let new_proxy_settings = request
.proxy_settings
.unwrap_or(proxy.proxy_settings.clone());
match PROXY_MANAGER.update_stored_proxy(
&state.app_handle,
@@ -775,7 +1082,7 @@ async fn update_proxy(
Ok(_) => Ok(Json(ApiProxyResponse {
id,
name: new_name,
proxy_settings: serde_json::to_value(new_proxy_settings).unwrap_or_default(),
proxy_settings: new_proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
}
@@ -784,6 +1091,23 @@ async fn update_proxy(
}
}
#[utoipa::path(
delete,
path = "/v1/proxies/{id}",
params(
("id" = String, Path, description = "Proxy ID")
),
responses(
(status = 204, description = "Proxy deleted successfully"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "proxies"
)]
async fn delete_proxy(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -795,6 +1119,24 @@ async fn delete_proxy(
}
// API Handler - Run Profile with Remote Debugging
#[utoipa::path(
post,
path = "/v1/profiles/{id}/run",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = RunProfileRequest,
responses(
(status = 200, description = "Profile launched successfully", body = RunProfileResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn run_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -836,6 +1178,24 @@ async fn run_profile(
}
// API Handler - Open URL in existing browser
#[utoipa::path(
post,
path = "/v1/profiles/{id}/open-url",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = OpenUrlRequest,
responses(
(status = 200, description = "URL opened successfully"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn open_url_in_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -852,6 +1212,23 @@ async fn open_url_in_profile(
}
// API Handler - Kill browser process
#[utoipa::path(
post,
path = "/v1/profiles/{id}/kill",
params(
("id" = String, Path, description = "Profile ID")
),
responses(
(status = 204, description = "Browser process killed successfully"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn kill_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -876,6 +1253,20 @@ async fn kill_profile(
}
// API Handler - Download Browser
#[utoipa::path(
post,
path = "/v1/browsers/download",
request_body = DownloadBrowserRequest,
responses(
(status = 200, description = "Browser download initiated", body = DownloadBrowserResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "browsers"
)]
async fn download_browser_api(
State(state): State<ApiServerState>,
Json(request): Json<DownloadBrowserRequest>,
@@ -897,6 +1288,22 @@ async fn download_browser_api(
}
// API Handler - Get Browser Versions
#[utoipa::path(
get,
path = "/v1/browsers/{browser}/versions",
params(
("browser" = String, Path, description = "Browser name")
),
responses(
(status = 200, description = "List of available browser versions", body = Vec<String>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "browsers"
)]
async fn get_browser_versions(
Path(browser): Path<String>,
State(_state): State<ApiServerState>,
@@ -913,6 +1320,23 @@ async fn get_browser_versions(
}
// API Handler - Check if Browser is Downloaded
#[utoipa::path(
get,
path = "/v1/browsers/{browser}/versions/{version}/downloaded",
params(
("browser" = String, Path, description = "Browser name"),
("version" = String, Path, description = "Browser version")
),
responses(
(status = 200, description = "Browser download status", body = bool),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "browsers"
)]
async fn check_browser_downloaded(
Path((browser, version)): Path<(String, String)>,
State(_state): State<ApiServerState>,
+16
View File
@@ -999,6 +999,22 @@ impl AppAutoUpdater {
// Clean up backup after successful installation
let _ = fs::remove_dir_all(&backup_path);
// Clean up old "Donut Browser.app" if it exists (from before the project rename)
if let Some(parent_dir) = current_app_path.parent() {
let old_app_path = parent_dir.join("Donut Browser.app");
if old_app_path.exists() && old_app_path != current_app_path {
log::info!(
"Removing old 'Donut Browser.app' from: {}",
old_app_path.display()
);
if let Err(e) = fs::remove_dir_all(&old_app_path) {
log::warn!("Warning: Failed to remove old 'Donut Browser.app': {e}");
} else {
log::info!("Successfully removed old 'Donut Browser.app'");
}
}
}
Ok(())
}
+1
View File
@@ -517,6 +517,7 @@ mod tests {
camoufox_config: None,
group_id: None,
tags: Vec::new(),
note: None,
}
}
+60 -2
View File
@@ -1,11 +1,60 @@
use clap::{Arg, Command};
use donutbrowser_lib::proxy_runner::{
start_proxy_process, stop_all_proxy_processes, stop_proxy_process,
start_proxy_process_with_profile, stop_all_proxy_processes, stop_proxy_process,
};
use donutbrowser_lib::proxy_server::run_proxy_server;
use donutbrowser_lib::proxy_storage::get_proxy_config;
use std::process;
fn set_high_priority() {
#[cfg(unix)]
{
unsafe {
// Set high priority (negative nice value = higher priority)
// -10 is a reasonably high priority without being too aggressive
// This may fail without elevated privileges, which is fine
let result = libc::setpriority(libc::PRIO_PROCESS, 0, -10);
if result == 0 {
log::info!("Set process priority to -10 (high priority)");
} else {
// Try a less aggressive priority if -10 fails
let result = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
if result == 0 {
log::info!("Set process priority to -5 (above normal)");
}
}
}
}
#[cfg(target_os = "linux")]
{
// Lower OOM score so this process is less likely to be killed under memory pressure
// Valid range is -1000 to 1000, lower = less likely to be killed
// -500 is a reasonable value that makes us less likely to be killed
if let Err(e) = std::fs::write("/proc/self/oom_score_adj", "-500") {
log::debug!("Could not set OOM score adjustment: {}", e);
} else {
log::info!("Set OOM score adjustment to -500");
}
}
#[cfg(windows)]
{
use windows::Win32::System::Threading::{
GetCurrentProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS,
};
unsafe {
let process = GetCurrentProcess();
if SetPriorityClass(process, ABOVE_NORMAL_PRIORITY_CLASS).is_ok() {
log::info!("Set process priority to ABOVE_NORMAL_PRIORITY_CLASS");
} else {
log::debug!("Could not set process priority class");
}
}
}
}
fn build_proxy_url(
proxy_type: &str,
host: &str,
@@ -87,6 +136,11 @@ async fn main() {
.short('u')
.long("upstream")
.help("Upstream proxy URL (protocol://[username:password@]host:port)"),
)
.arg(
Arg::new("profile-id")
.long("profile-id")
.help("ID of the profile this proxy is associated with"),
),
)
.subcommand(
@@ -138,8 +192,9 @@ async fn main() {
}
let port = start_matches.get_one::<u16>("port").copied();
let profile_id = start_matches.get_one::<String>("profile-id").cloned();
match start_proxy_process(upstream_url, port).await {
match start_proxy_process_with_profile(upstream_url, port, profile_id).await {
Ok(config) => {
// Output the configuration as JSON for the Rust side to parse
// Use println! here because this needs to go to stdout for parsing
@@ -224,6 +279,9 @@ async fn main() {
.expect("action is required");
if action == "start" {
// Set high priority so this process is killed last under resource pressure
set_high_priority();
log::error!("Proxy worker starting, looking for config id: {}", id);
log::error!("Process PID: {}", std::process::id());
+6 -1
View File
@@ -1,7 +1,8 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProxySettings {
pub proxy_type: String, // "http", "https", "socks4", or "socks5"
pub host: String,
@@ -736,6 +737,10 @@ impl Browser for ChromiumBrowser {
"--disable-background-timer-throttling".to_string(),
"--crash-server-url=".to_string(),
"--disable-updater".to_string(),
// Disable quit confirmation and session restore prompts
"--disable-session-crashed-bubble".to_string(),
"--hide-crash-restore-bubble".to_string(),
"--disable-infobars".to_string(),
];
// Add remote debugging if requested
+93 -11
View File
@@ -149,12 +149,13 @@ impl BrowserRunner {
// Start the proxy and get local proxy settings
// If proxy startup fails, DO NOT launch Camoufox - it requires local proxy
let profile_id_str = profile.id.to_string();
let local_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
0, // Use 0 as temporary PID, will be updated later
Some(&profile.name),
Some(&profile_id_str),
)
.await
.map_err(|e| {
@@ -214,6 +215,10 @@ impl BrowserRunner {
updated_camoufox_config.fingerprint = Some(new_fingerprint);
// Preserve the randomize flag so it persists across launches
updated_camoufox_config.randomize_fingerprint_on_launch = Some(true);
// Preserve the OS setting so it's used for future fingerprint generation
if camoufox_config.os.is_some() {
updated_camoufox_config.os = camoufox_config.os.clone();
}
updated_profile.camoufox_config = Some(updated_camoufox_config.clone());
log::info!(
@@ -819,6 +824,7 @@ impl BrowserRunner {
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
let profile_id_str = profile.id.to_string();
// Start local proxy - if this fails, DO NOT launch browser
let internal_proxy = PROXY_MANAGER
@@ -826,7 +832,7 @@ impl BrowserRunner {
app_handle.clone(),
upstream_proxy.as_ref(),
temp_pid,
Some(&profile.name),
Some(&profile_id_str),
)
.await
.map_err(|e| {
@@ -1058,6 +1064,19 @@ impl BrowserRunner {
profile.id
);
// Stop the proxy associated with this profile first
let profile_id_str = profile.id.to_string();
if let Err(e) = PROXY_MANAGER
.stop_proxy_by_profile_id(app_handle.clone(), &profile_id_str)
.await
{
log::warn!(
"Warning: Failed to stop proxy for profile {}: {e}",
profile_id_str
);
}
let mut process_actually_stopped = false;
match self
.camoufox_manager
.find_camoufox_by_profile(&profile_path_str)
@@ -1077,13 +1096,69 @@ impl BrowserRunner {
{
Ok(stopped) => {
if stopped {
log::info!(
"Successfully stopped Camoufox process: {} (PID: {:?})",
camoufox_process.id,
camoufox_process.processId
);
// Verify the process actually died by checking after a short delay
if let Some(pid) = camoufox_process.processId {
use tokio::time::{sleep, Duration};
sleep(Duration::from_millis(500)).await;
use sysinfo::{Pid, System};
let system = System::new_all();
process_actually_stopped = system.process(Pid::from(pid as usize)).is_none();
if process_actually_stopped {
log::info!(
"Successfully stopped Camoufox process: {} (PID: {:?}) - verified process is dead",
camoufox_process.id,
pid
);
} else {
log::warn!(
"Camoufox stop command returned success but process {} (PID: {:?}) is still running - forcing kill",
camoufox_process.id,
pid
);
// Force kill the process
#[cfg(target_os = "macos")]
{
use crate::platform_browser;
if let Err(e) = platform_browser::macos::kill_browser_process_impl(
pid,
Some(&profile_path_str),
)
.await
{
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
} else {
process_actually_stopped = true;
}
}
#[cfg(target_os = "linux")]
{
use crate::platform_browser;
if let Err(e) = platform_browser::linux::kill_browser_process_impl(pid).await
{
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
} else {
process_actually_stopped = true;
}
}
#[cfg(target_os = "windows")]
{
use crate::platform_browser;
if let Err(e) =
platform_browser::windows::kill_browser_process_impl(pid).await
{
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
} else {
process_actually_stopped = true;
}
}
}
} else {
process_actually_stopped = true; // No PID to verify, assume stopped
}
} else {
log::info!(
log::warn!(
"Failed to stop Camoufox process: {} (PID: {:?})",
camoufox_process.id,
camoufox_process.processId
@@ -1091,7 +1166,7 @@ impl BrowserRunner {
}
}
Err(e) => {
log::info!(
log::error!(
"Error stopping Camoufox process {}: {}",
camoufox_process.id,
e
@@ -1105,9 +1180,10 @@ impl BrowserRunner {
profile.name,
profile.id
);
process_actually_stopped = true; // No process found, consider it stopped
}
Err(e) => {
log::info!(
log::error!(
"Error finding Camoufox process for profile {}: {}",
profile.name,
e
@@ -1115,6 +1191,11 @@ impl BrowserRunner {
}
}
// Log warning if process wasn't confirmed stopped, but continue with cleanup
if !process_actually_stopped {
log::warn!("Camoufox process may still be running, but proceeding with cleanup");
}
// Clear the process ID from the profile
let mut updated_profile = profile.clone();
updated_profile.process_id = None;
@@ -1691,6 +1772,7 @@ pub async fn launch_browser_profile(
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
let profile_id_str = profile.id.to_string();
// Always start a local proxy, even if there's no upstream proxy
// This allows for traffic monitoring and future features
@@ -1699,7 +1781,7 @@ pub async fn launch_browser_profile(
app_handle.clone(),
upstream_proxy.as_ref(),
temp_pid,
Some(&profile.name),
Some(&profile_id_str),
)
.await
{
+116 -20
View File
@@ -23,6 +23,7 @@ pub struct CamoufoxConfig {
pub executable_path: Option<String>,
pub fingerprint: Option<String>, // JSON string of the complete fingerprint config
pub randomize_fingerprint_on_launch: Option<bool>, // Generate new fingerprint on every launch
pub os: Option<String>, // Operating system for fingerprint generation: "windows", "macos", or "linux"
}
impl Default for CamoufoxConfig {
@@ -40,6 +41,7 @@ impl Default for CamoufoxConfig {
executable_path: None,
fingerprint: None,
randomize_fingerprint_on_launch: None,
os: None,
}
}
}
@@ -171,6 +173,11 @@ impl CamoufoxManager {
}
}
// Add OS option for fingerprint generation
if let Some(os) = &config.os {
config_args.extend(["--os".to_string(), os.clone()]);
}
// Execute config generation command
let mut config_sidecar = self.get_nodecar_sidecar(app_handle)?;
for arg in &config_args {
@@ -342,6 +349,8 @@ impl CamoufoxManager {
}
/// Find Camoufox server by profile path (for integration with browser_runner)
/// This method first checks in-memory instances, then scans system processes
/// to detect Camoufox instances that may have been started before the app restarted.
pub async fn find_camoufox_by_profile(
&self,
profile_path: &str,
@@ -349,41 +358,127 @@ impl CamoufoxManager {
// First clean up any dead instances
self.cleanup_dead_instances().await?;
let inner = self.inner.lock().await;
// Convert paths to canonical form for comparison
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
for (id, instance) in inner.instances.iter() {
if let Some(instance_profile_path) = &instance.profile_path {
let instance_path = std::path::Path::new(instance_profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(instance_profile_path).to_path_buf());
// Check in-memory instances first
{
let inner = self.inner.lock().await;
if instance_path == target_path {
// Verify the server is actually running by checking the process
if let Some(process_id) = instance.process_id {
if self.is_server_running(process_id).await {
// Found running Camoufox instance
return Ok(Some(CamoufoxLaunchResult {
id: id.clone(),
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
}));
} else {
// Camoufox instance found but process is not running
for (id, instance) in inner.instances.iter() {
if let Some(instance_profile_path) = &instance.profile_path {
let instance_path = std::path::Path::new(instance_profile_path)
.canonicalize()
.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 process
if let Some(process_id) = instance.process_id {
if self.is_server_running(process_id).await {
// Found running Camoufox instance
return Ok(Some(CamoufoxLaunchResult {
id: id.clone(),
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
}));
}
}
}
}
}
}
// If not found in in-memory instances, scan system processes
// This handles the case where the app was restarted but Camoufox is still running
if let Some((pid, found_profile_path)) = self.find_camoufox_process_by_profile(&target_path) {
log::info!(
"Found running Camoufox process (PID: {}) for profile path via system scan",
pid
);
// Register this instance in our tracking
let instance_id = format!("recovered_{}", pid);
let mut inner = self.inner.lock().await;
inner.instances.insert(
instance_id.clone(),
CamoufoxInstance {
id: instance_id.clone(),
process_id: Some(pid),
profile_path: Some(found_profile_path.clone()),
url: None,
},
);
return Ok(Some(CamoufoxLaunchResult {
id: instance_id,
processId: Some(pid),
profilePath: Some(found_profile_path),
url: None,
}));
}
Ok(None)
}
/// Scan system processes to find a Camoufox process using a specific profile path
fn find_camoufox_process_by_profile(
&self,
target_path: &std::path::Path,
) -> Option<(u32, String)> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let target_path_str = target_path.to_string_lossy();
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.is_empty() {
continue;
}
// Check if this is a Camoufox/Firefox process
let exe_name = process.name().to_string_lossy().to_lowercase();
let is_firefox_like = exe_name.contains("firefox")
|| exe_name.contains("camoufox")
|| exe_name.contains("firefox-bin");
if !is_firefox_like {
continue;
}
// Check if the command line contains our profile path
for (i, arg) in cmd.iter().enumerate() {
if let Some(arg_str) = arg.to_str() {
// Check for -profile argument followed by our path
if arg_str == "-profile" && i + 1 < cmd.len() {
if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) {
let cmd_path = std::path::Path::new(next_arg)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(next_arg).to_path_buf());
if cmd_path == target_path {
return Some((pid.as_u32(), next_arg.to_string()));
}
}
}
// Also check if the argument contains the profile path directly
if arg_str.contains(&*target_path_str) {
return Some((pid.as_u32(), target_path_str.to_string()));
}
}
}
}
None
}
/// Check if servers are still alive and clean up dead instances
pub async fn cleanup_dead_instances(
&self,
@@ -496,6 +591,7 @@ mod tests {
assert_eq!(default_config.proxy, None);
assert_eq!(default_config.fingerprint, None);
assert_eq!(default_config.randomize_fingerprint_on_launch, None);
assert_eq!(default_config.os, None);
}
}
+35 -2
View File
@@ -30,6 +30,7 @@ pub mod proxy_runner;
pub mod proxy_server;
pub mod proxy_storage;
mod settings_manager;
pub mod traffic_stats;
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
mod tag_manager;
mod version_updater;
@@ -40,7 +41,8 @@ use browser_runner::{
use profile::manager::{
check_browser_status, create_browser_profile_new, delete_profile, list_browser_profiles,
rename_profile, update_camoufox_config, update_profile_proxy, update_profile_tags,
rename_profile, update_camoufox_config, update_profile_note, update_profile_proxy,
update_profile_tags,
};
use browser_version_manager::{
@@ -245,6 +247,33 @@ async fn is_geoip_database_available() -> Result<bool, String> {
Ok(GeoIPDownloader::is_geoip_database_available())
}
#[tauri::command]
async fn get_all_traffic_snapshots() -> Result<Vec<crate::traffic_stats::TrafficSnapshot>, String> {
Ok(
crate::traffic_stats::list_traffic_stats()
.into_iter()
.map(|s| s.to_snapshot())
.collect(),
)
}
#[tauri::command]
async fn clear_all_traffic_stats() -> Result<(), String> {
crate::traffic_stats::clear_all_traffic_stats()
.map_err(|e| format!("Failed to clear traffic stats: {e}"))
}
#[tauri::command]
async fn get_traffic_stats_for_period(
profile_id: String,
seconds: u64,
) -> Result<Option<crate::traffic_stats::FilteredTrafficStats>, String> {
Ok(crate::traffic_stats::get_traffic_stats_for_period(
&profile_id,
seconds,
))
}
#[tauri::command]
async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), String> {
let downloader = GeoIPDownloader::instance();
@@ -710,6 +739,7 @@ pub fn run() {
get_browser_release_types,
update_profile_proxy,
update_profile_tags,
update_profile_note,
check_browser_status,
kill_browser_profile,
rename_profile,
@@ -754,7 +784,10 @@ pub fn run() {
warm_up_nodecar,
start_api_server,
stop_api_server,
get_api_server_status
get_api_server_status,
get_all_traffic_snapshots,
clear_all_traffic_stats,
get_traffic_stats_for_period
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+85 -11
View File
@@ -165,6 +165,7 @@ impl ProfileManager {
camoufox_config: None,
group_id: group_id.clone(),
tags: Vec::new(),
note: None,
};
match self
@@ -207,6 +208,7 @@ impl ProfileManager {
camoufox_config: final_camoufox_config,
group_id: group_id.clone(),
tags: Vec::new(),
note: None,
};
// Save profile info
@@ -522,6 +524,35 @@ impl ProfileManager {
Ok(profile)
}
pub fn update_profile_note(
&self,
app_handle: &tauri::AppHandle,
profile_id: &str,
note: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
// Find the profile by ID
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let mut profile = profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Update note (trim whitespace, set to None if empty)
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
// Save profile
self.save_profile(&profile)?;
// Emit profile note update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub fn delete_multiple_profiles(
&self,
app_handle: &tauri::AppHandle,
@@ -1040,7 +1071,7 @@ impl ProfileManager {
fn get_common_firefox_preferences(&self) -> Vec<String> {
vec![
// Disable default browser updates
// Disable default browser check
"user_pref(\"browser.shell.checkDefaultBrowser\", false);".to_string(),
"user_pref(\"browser.shell.skipDefaultBrowserCheckOnFirstRun\", true);".to_string(),
"user_pref(\"browser.preferences.moreFromMozilla\", false);".to_string(),
@@ -1055,27 +1086,58 @@ impl ProfileManager {
// Keep extension updates enabled
"user_pref(\"extensions.update.enabled\", true);".to_string(),
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
// Completely disable browser update checking
"user_pref(\"app.update.enabled\", false);".to_string(),
"user_pref(\"app.update.staging.enabled\", false);".to_string(),
"user_pref(\"app.update.timerFirstInterval\", -1);".to_string(),
"user_pref(\"app.update.download.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.elevate.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.disabledForTesting\", true);".to_string(),
"user_pref(\"app.update.auto\", false);".to_string(),
"user_pref(\"app.update.mode\", 0);".to_string(),
"user_pref(\"app.update.promptWaitTime\", -1);".to_string(),
"user_pref(\"app.update.service.enabled\", false);".to_string(),
"user_pref(\"app.update.staging.enabled\", false);".to_string(),
"user_pref(\"app.update.silent\", true);".to_string(),
"user_pref(\"app.update.disabledForTesting\", true);".to_string(),
// Prevent update URL access entirely
"user_pref(\"app.update.url\", \"\");".to_string(),
"user_pref(\"app.update.url.manual\", \"\");".to_string(),
"user_pref(\"app.update.url.details\", \"\");".to_string(),
// Disable update timing/scheduling
"user_pref(\"app.update.timerFirstInterval\", 999999999);".to_string(),
"user_pref(\"app.update.interval\", 999999999);".to_string(),
"user_pref(\"app.update.background.interval\", 999999999);".to_string(),
"user_pref(\"app.update.idletime\", 999999999);".to_string(),
"user_pref(\"app.update.promptWaitTime\", 999999999);".to_string(),
// Disable update attempts
"user_pref(\"app.update.download.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.elevate.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.checkInstallTime\", false);".to_string(),
"user_pref(\"app.update.interval\", -1);".to_string(),
"user_pref(\"app.update.background.interval\", -1);".to_string(),
"user_pref(\"app.update.idletime\", -1);".to_string(),
// Suppress additional update UI/prompts
// Suppress update UI/prompts/notifications
"user_pref(\"app.update.doorhanger\", false);".to_string(),
"user_pref(\"app.update.badge\", false);".to_string(),
"user_pref(\"app.update.notifyDuringDownload\", false);".to_string(),
"user_pref(\"app.update.background.scheduling.enabled\", false);".to_string(),
"user_pref(\"app.update.background.enabled\", false);".to_string(),
// Disable BITS (Windows Background Intelligent Transfer Service) updates
"user_pref(\"app.update.BITS.enabled\", false);".to_string(),
// Disable language pack updates
"user_pref(\"app.update.langpack.enabled\", false);".to_string(),
// Suppress upgrade dialogs on startup
"user_pref(\"browser.startup.upgradeDialog.enabled\", false);".to_string(),
// Disable update ping telemetry
"user_pref(\"toolkit.telemetry.updatePing.enabled\", false);".to_string(),
// Zen browser specific - disable welcome screen and updates
"user_pref(\"zen.welcome-screen.seen\", true);".to_string(),
"user_pref(\"zen.updates.enabled\", false);".to_string(),
"user_pref(\"zen.updates.check-for-updates\", false);".to_string(),
// Additional first-run suppressions
"user_pref(\"app.normandy.first_run\", false);".to_string(),
"user_pref(\"trailhead.firstrun.didSeeAboutWelcome\", true);".to_string(),
"user_pref(\"datareporting.policy.dataSubmissionPolicyBypassNotification\", true);"
.to_string(),
"user_pref(\"toolkit.telemetry.reportingpolicy.firstRun\", false);".to_string(),
// Disable quit confirmation dialogs
"user_pref(\"browser.warnOnQuit\", false);".to_string(),
"user_pref(\"browser.showQuitWarning\", false);".to_string(),
"user_pref(\"browser.tabs.warnOnClose\", false);".to_string(),
"user_pref(\"browser.tabs.warnOnCloseOtherTabs\", false);".to_string(),
"user_pref(\"browser.sessionstore.warnOnQuit\", false);".to_string(),
]
}
@@ -1445,6 +1507,18 @@ pub fn update_profile_tags(
.map_err(|e| format!("Failed to update profile tags: {e}"))
}
#[tauri::command]
pub fn update_profile_note(
app_handle: tauri::AppHandle,
profile_id: String,
note: Option<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_note(&app_handle, &profile_id, note)
.map_err(|e| format!("Failed to update profile note: {e}"))
}
#[tauri::command]
pub async fn check_browser_status(
app_handle: tauri::AppHandle,
+2
View File
@@ -22,6 +22,8 @@ pub struct BrowserProfile {
pub group_id: Option<String>, // Reference to profile group
#[serde(default)]
pub tags: Vec<String>, // Free-form tags
#[serde(default)]
pub note: Option<String>, // User note
}
pub fn default_release_type() -> String {
+1
View File
@@ -561,6 +561,7 @@ impl ProfileImporter {
camoufox_config: None,
group_id: None,
tags: Vec::new(),
note: None,
};
// Save the profile metadata
+108 -23
View File
@@ -20,8 +20,8 @@ pub struct ProxyInfo {
pub upstream_port: u16,
pub upstream_type: String,
pub local_port: u16,
// Optional profile name to which this proxy instance is logically tied
pub profile_name: Option<String>,
// Optional profile ID to which this proxy instance is logically tied
pub profile_id: Option<String>,
}
// Proxy check result cache
@@ -491,7 +491,6 @@ impl ProxyManager {
"https://ipinfo.io/ip",
"https://icanhazip.com",
"https://ifconfig.co/ip",
"https://ipecho.net/plain",
];
// Create HTTP client with proxy
@@ -594,14 +593,14 @@ impl ProxyManager {
app_handle: tauri::AppHandle,
proxy_settings: Option<&ProxySettings>,
browser_pid: u32,
profile_name: Option<&str>,
profile_id: Option<&str>,
) -> Result<ProxySettings, String> {
// First, proactively cleanup any dead proxies so we don't accidentally reuse stale ones
let _ = self.cleanup_dead_proxies(app_handle.clone()).await;
// If we have a previous proxy tied to this profile, and the upstream settings are changing,
// stop it before starting a new one so the change takes effect immediately.
if let Some(name) = profile_name {
if let Some(name) = profile_id {
// Check if we have an active proxy recorded for this profile
let maybe_existing_id = {
let map = self.profile_active_proxy_ids.lock().unwrap();
@@ -664,14 +663,32 @@ impl ProxyManager {
&& existing.upstream_port == desired_port;
if is_same_upstream {
// Reuse existing local proxy
return Ok(ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: existing.local_port,
username: None,
password: None,
});
// Check if profile_id matches - if not, we need to restart to update tracking
let profile_id_matches = match (profile_id, &existing.profile_id) {
(Some(ref new_id), Some(ref old_id)) => new_id == old_id,
(None, None) => true,
_ => false,
};
if profile_id_matches {
// Reuse existing local proxy (profile_id matches)
return Ok(ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: existing.local_port,
username: None,
password: None,
});
} else {
// Profile ID changed - need to restart proxy to update tracking
log::info!(
"Profile ID changed for proxy {}: {:?} -> {:?}, restarting proxy",
existing.id,
existing.profile_id,
profile_id
);
needs_restart = true;
}
} else {
// Upstream changed; we must restart the local proxy so that traffic is routed correctly
needs_restart = true;
@@ -711,6 +728,11 @@ impl ProxyManager {
}
}
// Add profile ID if provided for traffic tracking
if let Some(id) = profile_id {
proxy_cmd = proxy_cmd.arg("--profile-id").arg(id);
}
// Execute the command and wait for it to complete
// The donut-proxy binary should start the worker and then exit
let output = proxy_cmd
@@ -755,7 +777,7 @@ impl ProxyManager {
.map(|p| p.proxy_type.clone())
.unwrap_or_else(|| "DIRECT".to_string()),
local_port,
profile_name: profile_name.map(|s| s.to_string()),
profile_id: profile_id.map(|s| s.to_string()),
};
// Wait for the local proxy port to be ready to accept connections
@@ -789,14 +811,14 @@ impl ProxyManager {
}
// Store the profile proxy info for persistence
if let Some(name) = profile_name {
if let Some(id) = profile_id {
if let Some(proxy_settings) = proxy_settings {
let mut profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.insert(name.to_string(), proxy_settings.clone());
profile_proxies.insert(id.to_string(), proxy_settings.clone());
}
// Also record the active proxy id for this profile for quick cleanup on changes
let mut map = self.profile_active_proxy_ids.lock().unwrap();
map.insert(name.to_string(), proxy_info.id.clone());
map.insert(id.to_string(), proxy_info.id.clone());
}
// Return proxy settings for the browser
@@ -815,10 +837,10 @@ impl ProxyManager {
app_handle: tauri::AppHandle,
browser_pid: u32,
) -> Result<(), String> {
let (proxy_id, profile_name): (String, Option<String>) = {
let (proxy_id, profile_id): (String, Option<String>) = {
let mut proxies = self.active_proxies.lock().unwrap();
match proxies.remove(&browser_pid) {
Some(proxy) => (proxy.id, proxy.profile_name.clone()),
Some(proxy) => (proxy.id, proxy.profile_id.clone()),
None => return Ok(()), // No proxy to stop
}
};
@@ -842,11 +864,11 @@ impl ProxyManager {
}
// Clear profile-to-proxy mapping if it references this proxy
if let Some(name) = profile_name {
if let Some(id) = profile_id {
let mut map = self.profile_active_proxy_ids.lock().unwrap();
if let Some(current_id) = map.get(&name) {
if let Some(current_id) = map.get(&id) {
if current_id == &proxy_id {
map.remove(&name);
map.remove(&id);
}
}
}
@@ -859,6 +881,69 @@ impl ProxyManager {
Ok(())
}
// Stop the proxy associated with a profile ID
pub async fn stop_proxy_by_profile_id(
&self,
app_handle: tauri::AppHandle,
profile_id: &str,
) -> Result<(), String> {
// Find the proxy ID for this profile
let proxy_id = {
let map = self.profile_active_proxy_ids.lock().unwrap();
map.get(profile_id).cloned()
};
if let Some(proxy_id) = proxy_id {
// Find the PID for this proxy
let pid = {
let proxies = self.active_proxies.lock().unwrap();
proxies.iter().find_map(|(pid, proxy)| {
if proxy.id == proxy_id {
Some(*pid)
} else {
None
}
})
};
if let Some(pid) = pid {
// Use the existing stop_proxy method
self.stop_proxy(app_handle, pid).await
} else {
// Proxy not found in active_proxies, try to stop it directly by ID
let proxy_cmd = app_handle
.shell()
.sidecar("donut-proxy")
.map_err(|e| format!("Failed to create sidecar: {e}"))?
.arg("proxy")
.arg("stop")
.arg("--id")
.arg(&proxy_id);
let output = proxy_cmd.output().await.unwrap();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("Proxy stop error: {stderr}");
}
// Clear profile-to-proxy mapping
let mut map = self.profile_active_proxy_ids.lock().unwrap();
map.remove(profile_id);
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
log::error!("Failed to emit proxies-changed event: {e}");
}
Ok(())
}
} else {
// No proxy found for this profile
Ok(())
}
}
// Update the PID mapping for an existing proxy
pub fn update_proxy_pid(&self, old_pid: u32, new_pid: u32) -> Result<(), String> {
let mut proxies = self.active_proxies.lock().unwrap();
@@ -1035,7 +1120,7 @@ mod tests {
upstream_port: 3128,
upstream_type: "http".to_string(),
local_port: (8000 + i) as u16,
profile_name: None,
profile_id: None,
};
// Add proxy
+36 -1
View File
@@ -11,6 +11,14 @@ lazy_static::lazy_static! {
pub async fn start_proxy_process(
upstream_url: Option<String>,
port: Option<u16>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
start_proxy_process_with_profile(upstream_url, port, None).await
}
pub async fn start_proxy_process_with_profile(
upstream_url: Option<String>,
port: Option<u16>,
profile_id: Option<String>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
let id = generate_proxy_id();
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
@@ -22,9 +30,17 @@ pub async fn start_proxy_process(
listener.local_addr().unwrap().port()
});
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port));
let config =
ProxyConfig::new(id.clone(), upstream, Some(local_port)).with_profile_id(profile_id.clone());
save_proxy_config(&config)?;
// Log profile_id for debugging
if let Some(ref pid) = profile_id {
log::info!("Saved proxy config {} with profile_id: {}", id, pid);
} else {
log::info!("Saved proxy config {} without profile_id", id);
}
// Spawn proxy worker process in the background using std::process::Command
// This ensures proper process detachment on Unix systems
let exe = std::env::current_exe()?;
@@ -63,6 +79,13 @@ pub async fn start_proxy_process(
cmd.pre_exec(|| {
// Create a new process group so the process survives parent exit
libc::setsid();
// Set high priority so the proxy is killed last under resource pressure
// Negative nice value = higher priority. Try -10, fall back to -5 if it fails.
if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 {
let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
}
Ok(())
});
}
@@ -90,6 +113,10 @@ pub async fn start_proxy_process(
{
use std::os::windows::process::CommandExt;
use std::process::Command as StdCommand;
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Threading::{
OpenProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS, PROCESS_SET_INFORMATION,
};
let mut cmd = StdCommand::new(&exe);
cmd.arg("proxy-worker");
@@ -108,6 +135,14 @@ pub async fn start_proxy_process(
let child = cmd.spawn()?;
let pid = child.id();
// Set high priority so the proxy is killed last under resource pressure
unsafe {
if let Ok(handle) = OpenProcess(PROCESS_SET_INFORMATION, false, pid) {
let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS);
let _ = CloseHandle(handle);
}
}
// Store PID
{
let mut processes = PROXY_PROCESSES.lock().unwrap();
+154 -5
View File
@@ -1,4 +1,5 @@
use crate::proxy_storage::ProxyConfig;
use crate::traffic_stats::{get_traffic_tracker, init_traffic_tracker};
use http_body_util::{BodyExt, Full};
use hyper::body::Bytes;
use hyper::server::conn::http1;
@@ -9,12 +10,81 @@ use std::convert::Infallible;
use std::io;
use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use url::Url;
/// Wrapper stream that counts bytes read and written
struct CountingStream<S> {
inner: S,
bytes_read: Arc<AtomicU64>,
bytes_written: Arc<AtomicU64>,
}
impl<S> CountingStream<S> {
fn new(inner: S) -> Self {
Self {
inner,
bytes_read: Arc::new(AtomicU64::new(0)),
bytes_written: Arc::new(AtomicU64::new(0)),
}
}
}
impl<S: AsyncRead + Unpin> AsyncRead for CountingStream<S> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let filled_before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &result {
let bytes_read = buf.filled().len() - filled_before;
if bytes_read > 0 {
self
.bytes_read
.fetch_add(bytes_read as u64, Ordering::Relaxed);
// Update global tracker - count as received (data coming into proxy)
if let Some(tracker) = get_traffic_tracker() {
tracker.add_bytes_received(bytes_read as u64);
}
}
}
result
}
}
impl<S: AsyncWrite + Unpin> AsyncWrite for CountingStream<S> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let result = Pin::new(&mut self.inner).poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = &result {
self.bytes_written.fetch_add(*n as u64, Ordering::Relaxed);
// Update global tracker - count as sent (data going out of proxy)
if let Some(tracker) = get_traffic_tracker() {
tracker.add_bytes_sent(*n as u64);
}
}
result
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
// Wrapper to prepend consumed bytes to a stream
struct PrependReader {
prepended: Vec<u8>,
@@ -297,6 +367,13 @@ async fn handle_http(
// This is faster and more reliable than trying to use hyper-proxy with version conflicts
use reqwest::Client;
// Extract domain for traffic tracking
let domain = req
.uri()
.host()
.map(|h| h.to_string())
.unwrap_or_else(|| "unknown".to_string());
let client_builder = Client::builder();
let client = if let Some(ref upstream) = upstream_url {
if upstream == "DIRECT" {
@@ -370,6 +447,12 @@ async fn handle_http(
let headers = response.headers().clone();
let body = response.bytes().await.unwrap_or_default();
// Record request in traffic tracker
let response_size = body.len() as u64;
if let Some(tracker) = get_traffic_tracker() {
tracker.record_request(&domain, body_bytes.len() as u64, response_size);
}
let mut hyper_response = Response::new(Full::new(body));
*hyper_response.status_mut() = StatusCode::from_u16(status.as_u16()).unwrap();
@@ -441,14 +524,35 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
};
log::error!(
"Found config: id={}, port={:?}, upstream={}",
"Found config: id={}, port={:?}, upstream={}, profile_id={:?}",
config.id,
config.local_port,
config.upstream_url
config.upstream_url,
config.profile_id
);
log::error!("Starting proxy server for config id: {}", config.id);
// Initialize traffic tracker with profile ID if available
// This can now be called multiple times to update the tracker
init_traffic_tracker(config.id.clone(), config.profile_id.clone());
log::error!(
"Traffic tracker initialized for proxy: {} (profile_id: {:?})",
config.id,
config.profile_id
);
// Verify tracker was initialized correctly
if let Some(tracker) = crate::traffic_stats::get_traffic_tracker() {
log::error!(
"Tracker verified: proxy_id={}, profile_id={:?}",
tracker.proxy_id,
tracker.profile_id
);
} else {
log::error!("WARNING: Tracker was not initialized!");
}
// Determine the bind address
let bind_addr = SocketAddr::from(([127, 0, 0, 1], config.local_port.unwrap_or(0)));
@@ -488,6 +592,19 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
);
log::error!("Proxy server entering accept loop - process should stay alive");
// Start a background task to periodically flush traffic stats to disk
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
loop {
interval.tick().await;
if let Some(tracker) = get_traffic_tracker() {
if let Err(e) = tracker.flush_to_disk() {
log::error!("Failed to flush traffic stats: {}", e);
}
}
}
});
// Keep the runtime alive with an infinite loop
// This ensures the process doesn't exit even if there are no active connections
loop {
@@ -605,6 +722,12 @@ async fn handle_connect_from_buffer(
(target, 443)
};
// Record domain access in traffic tracker
let domain = target_host.to_string();
if let Some(tracker) = get_traffic_tracker() {
tracker.record_request(&domain, 0, 0);
}
// Connect to target (directly or via upstream proxy)
let target_stream = if upstream_url.is_none()
|| upstream_url
@@ -693,10 +816,20 @@ async fn handle_connect_from_buffer(
log::error!("DEBUG: Sent 200 Connection Established response, starting tunnel");
// Now tunnel data bidirectionally
// Now tunnel data bidirectionally with counting
// Wrap streams to count bytes transferred
let counting_client = CountingStream::new(client_stream);
let counting_target = CountingStream::new(target_stream);
// Get references for final stats
let client_read_counter = counting_client.bytes_read.clone();
let client_write_counter = counting_client.bytes_written.clone();
let target_read_counter = counting_target.bytes_read.clone();
let target_write_counter = counting_target.bytes_written.clone();
// Split streams for bidirectional copying
let (mut client_read, mut client_write) = tokio::io::split(client_stream);
let (mut target_read, mut target_write) = tokio::io::split(target_stream);
let (mut client_read, mut client_write) = tokio::io::split(counting_client);
let (mut target_read, mut target_write) = tokio::io::split(counting_target);
log::error!("DEBUG: Starting bidirectional tunnel");
@@ -735,5 +868,21 @@ async fn handle_connect_from_buffer(
}
}
// Log final byte counts and update domain stats
let final_sent =
client_read_counter.load(Ordering::Relaxed) + target_write_counter.load(Ordering::Relaxed);
let final_recv =
target_read_counter.load(Ordering::Relaxed) + client_write_counter.load(Ordering::Relaxed);
log::error!(
"DEBUG: Tunnel closed - sent: {} bytes, received: {} bytes",
final_sent,
final_recv
);
// Update domain-specific byte counts now that tunnel is complete
if let Some(tracker) = get_traffic_tracker() {
tracker.update_domain_bytes(&domain, final_sent, final_recv);
}
Ok(())
}
+8
View File
@@ -11,6 +11,8 @@ pub struct ProxyConfig {
pub ignore_proxy_certificate: Option<bool>,
pub local_url: Option<String>,
pub pid: Option<u32>,
#[serde(default)]
pub profile_id: Option<String>,
}
impl ProxyConfig {
@@ -22,8 +24,14 @@ impl ProxyConfig {
ignore_proxy_certificate: None,
local_url: None,
pid: None,
profile_id: None,
}
}
pub fn with_profile_id(mut self, profile_id: Option<String>) -> Self {
self.profile_id = profile_id;
self
}
}
pub fn get_storage_dir() -> PathBuf {
+668
View File
@@ -0,0 +1,668 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
/// Individual bandwidth data point for time-series tracking
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BandwidthDataPoint {
/// Unix timestamp in seconds
pub timestamp: u64,
/// Bytes sent in this interval
pub bytes_sent: u64,
/// Bytes received in this interval
pub bytes_received: u64,
}
/// Domain access information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainAccess {
/// Domain name
pub domain: String,
/// Number of requests to this domain
pub request_count: u64,
/// Total bytes sent to this domain
pub bytes_sent: u64,
/// Total bytes received from this domain
pub bytes_received: u64,
/// First access timestamp
pub first_access: u64,
/// Last access timestamp
pub last_access: u64,
}
/// Lightweight snapshot for real-time updates (sent via events)
/// Contains only the data needed for the mini chart and summary display
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrafficSnapshot {
/// Profile ID (for matching)
pub profile_id: Option<String>,
/// Session start timestamp
pub session_start: u64,
/// Last update timestamp
pub last_update: u64,
/// Total bytes sent across all time
pub total_bytes_sent: u64,
/// Total bytes received across all time
pub total_bytes_received: u64,
/// Total requests made
pub total_requests: u64,
/// Current bandwidth (bytes per second) sent
pub current_bytes_sent: u64,
/// Current bandwidth (bytes per second) received
pub current_bytes_received: u64,
/// Recent bandwidth history (last 60 seconds only, for mini chart)
pub recent_bandwidth: Vec<BandwidthDataPoint>,
}
/// Traffic statistics for a profile/proxy session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrafficStats {
/// Proxy ID this stats belong to (for backwards compatibility)
pub proxy_id: String,
/// Profile ID (if associated) - this is now the primary key for storage
pub profile_id: Option<String>,
/// Session start timestamp
pub session_start: u64,
/// Last update timestamp
pub last_update: u64,
/// Total bytes sent across all time
pub total_bytes_sent: u64,
/// Total bytes received across all time
pub total_bytes_received: u64,
/// Total requests made
pub total_requests: u64,
/// Bandwidth data points (time-series, 1 point per second, stored indefinitely)
#[serde(default)]
pub bandwidth_history: Vec<BandwidthDataPoint>,
/// Domain access statistics
#[serde(default)]
pub domains: HashMap<String, DomainAccess>,
/// Unique IPs accessed
#[serde(default)]
pub unique_ips: Vec<String>,
}
impl TrafficStats {
pub fn new(proxy_id: String, profile_id: Option<String>) -> Self {
let now = current_timestamp();
Self {
proxy_id,
profile_id,
session_start: now,
last_update: now,
total_bytes_sent: 0,
total_bytes_received: 0,
total_requests: 0,
bandwidth_history: Vec::new(),
domains: HashMap::new(),
unique_ips: Vec::new(),
}
}
/// Create a lightweight snapshot for real-time UI updates
pub fn to_snapshot(&self) -> TrafficSnapshot {
let now = current_timestamp();
let cutoff = now.saturating_sub(60); // Last 60 seconds for mini chart
// Get current bandwidth from last data point
let (current_sent, current_recv) = self
.bandwidth_history
.last()
.filter(|dp| dp.timestamp >= now.saturating_sub(2)) // Within last 2 seconds
.map(|dp| (dp.bytes_sent, dp.bytes_received))
.unwrap_or((0, 0));
TrafficSnapshot {
profile_id: self.profile_id.clone(),
session_start: self.session_start,
last_update: self.last_update,
total_bytes_sent: self.total_bytes_sent,
total_bytes_received: self.total_bytes_received,
total_requests: self.total_requests,
current_bytes_sent: current_sent,
current_bytes_received: current_recv,
recent_bandwidth: self
.bandwidth_history
.iter()
.filter(|dp| dp.timestamp >= cutoff)
.cloned()
.collect(),
}
}
/// Record bandwidth for current second (data is stored indefinitely)
pub fn record_bandwidth(&mut self, bytes_sent: u64, bytes_received: u64) {
let now = current_timestamp();
self.last_update = now;
self.total_bytes_sent += bytes_sent;
self.total_bytes_received += bytes_received;
// Find or create data point for this second
if let Some(last) = self.bandwidth_history.last_mut() {
if last.timestamp == now {
last.bytes_sent += bytes_sent;
last.bytes_received += bytes_received;
return;
}
}
// Add new data point (even if bytes are zero, to ensure chart has continuous data)
self.bandwidth_history.push(BandwidthDataPoint {
timestamp: now,
bytes_sent,
bytes_received,
});
}
/// Record a request to a domain
pub fn record_request(&mut self, domain: &str, bytes_sent: u64, bytes_received: u64) {
let now = current_timestamp();
self.total_requests += 1;
let entry = self
.domains
.entry(domain.to_string())
.or_insert(DomainAccess {
domain: domain.to_string(),
request_count: 0,
bytes_sent: 0,
bytes_received: 0,
first_access: now,
last_access: now,
});
entry.request_count += 1;
entry.bytes_sent += bytes_sent;
entry.bytes_received += bytes_received;
entry.last_access = now;
}
/// Record an IP address access
pub fn record_ip(&mut self, ip: &str) {
if !self.unique_ips.contains(&ip.to_string()) {
self.unique_ips.push(ip.to_string());
}
}
/// Get bandwidth data for the last N seconds
pub fn get_recent_bandwidth(&self, seconds: u64) -> Vec<BandwidthDataPoint> {
let now = current_timestamp();
let cutoff = now.saturating_sub(seconds);
self
.bandwidth_history
.iter()
.filter(|dp| dp.timestamp >= cutoff)
.cloned()
.collect()
}
}
/// Get current Unix timestamp in seconds
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Get the traffic stats storage directory
pub fn get_traffic_stats_dir() -> PathBuf {
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
let mut path = base_dirs.cache_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("traffic_stats");
path
}
/// Get the storage key for traffic stats (profile_id if available, otherwise proxy_id)
fn get_stats_storage_key(stats: &TrafficStats) -> String {
stats
.profile_id
.clone()
.unwrap_or_else(|| stats.proxy_id.clone())
}
/// Save traffic stats to disk using profile_id as the key
pub fn save_traffic_stats(stats: &TrafficStats) -> Result<(), Box<dyn std::error::Error>> {
let storage_dir = get_traffic_stats_dir();
fs::create_dir_all(&storage_dir)?;
let key = get_stats_storage_key(stats);
let file_path = storage_dir.join(format!("{key}.json"));
let content = serde_json::to_string(stats)?;
fs::write(&file_path, content)?;
Ok(())
}
/// Load traffic stats from disk by profile_id or proxy_id
pub fn load_traffic_stats(id: &str) -> Option<TrafficStats> {
let storage_dir = get_traffic_stats_dir();
let file_path = storage_dir.join(format!("{id}.json"));
if !file_path.exists() {
return None;
}
let content = fs::read_to_string(&file_path).ok()?;
serde_json::from_str(&content).ok()
}
/// Load traffic stats by profile_id
pub fn load_traffic_stats_by_profile(profile_id: &str) -> Option<TrafficStats> {
load_traffic_stats(profile_id)
}
/// List all traffic stats files and migrate old proxy-id based files to profile-id based
pub fn list_traffic_stats() -> Vec<TrafficStats> {
let storage_dir = get_traffic_stats_dir();
if !storage_dir.exists() {
return Vec::new();
}
let mut stats_map: HashMap<String, TrafficStats> = HashMap::new();
let mut files_to_delete: Vec<std::path::PathBuf> = Vec::new();
if let Ok(entries) = fs::read_dir(&storage_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(s) = serde_json::from_str::<TrafficStats>(&content) {
// Determine the key for this stats entry
let key = s.profile_id.clone().unwrap_or_else(|| s.proxy_id.clone());
// Check if this is an old proxy-id based file that should be migrated
let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let is_old_proxy_file = file_stem.starts_with("proxy_")
&& s.profile_id.is_some()
&& file_stem != s.profile_id.as_ref().unwrap();
if let Some(existing) = stats_map.get_mut(&key) {
// Merge stats from this file into existing
merge_traffic_stats(existing, &s);
if is_old_proxy_file {
files_to_delete.push(path.clone());
}
} else {
stats_map.insert(key.clone(), s);
if is_old_proxy_file {
files_to_delete.push(path.clone());
}
}
}
}
}
}
}
// Save merged stats and delete old files
for stats in stats_map.values() {
if let Err(e) = save_traffic_stats(stats) {
log::warn!("Failed to save merged traffic stats: {}", e);
}
}
for path in files_to_delete {
if let Err(e) = fs::remove_file(&path) {
log::warn!("Failed to delete old traffic stats file {:?}: {}", path, e);
}
}
stats_map.into_values().collect()
}
/// Merge traffic stats from source into destination
fn merge_traffic_stats(dest: &mut TrafficStats, src: &TrafficStats) {
// Update totals
dest.total_bytes_sent += src.total_bytes_sent;
dest.total_bytes_received += src.total_bytes_received;
dest.total_requests += src.total_requests;
// Update timestamps
dest.session_start = dest.session_start.min(src.session_start);
dest.last_update = dest.last_update.max(src.last_update);
// Merge bandwidth history (keep all data, sorted by timestamp)
let mut combined_history: Vec<BandwidthDataPoint> = dest.bandwidth_history.clone();
for point in &src.bandwidth_history {
if !combined_history
.iter()
.any(|p| p.timestamp == point.timestamp)
{
combined_history.push(point.clone());
}
}
combined_history.sort_by_key(|p| p.timestamp);
dest.bandwidth_history = combined_history;
// Merge domains
for (domain, access) in &src.domains {
let entry = dest.domains.entry(domain.clone()).or_insert(DomainAccess {
domain: domain.clone(),
request_count: 0,
bytes_sent: 0,
bytes_received: 0,
first_access: access.first_access,
last_access: access.last_access,
});
entry.request_count += access.request_count;
entry.bytes_sent += access.bytes_sent;
entry.bytes_received += access.bytes_received;
entry.first_access = entry.first_access.min(access.first_access);
entry.last_access = entry.last_access.max(access.last_access);
}
// Merge unique IPs
for ip in &src.unique_ips {
if !dest.unique_ips.contains(ip) {
dest.unique_ips.push(ip.clone());
}
}
}
/// Delete traffic stats by id (profile_id or proxy_id)
pub fn delete_traffic_stats(id: &str) -> bool {
let storage_dir = get_traffic_stats_dir();
let file_path = storage_dir.join(format!("{id}.json"));
if file_path.exists() {
fs::remove_file(&file_path).is_ok()
} else {
false
}
}
/// Clear all traffic stats (used when clearing cache)
pub fn clear_all_traffic_stats() -> Result<(), Box<dyn std::error::Error>> {
let storage_dir = get_traffic_stats_dir();
if storage_dir.exists() {
for entry in fs::read_dir(&storage_dir)?.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
let _ = fs::remove_file(&path);
}
}
}
Ok(())
}
/// Live bandwidth tracker for real-time stats collection in the proxy
/// This is designed to be used from within the proxy server
pub struct LiveTrafficTracker {
pub proxy_id: String,
pub profile_id: Option<String>,
bytes_sent: AtomicU64,
bytes_received: AtomicU64,
requests: AtomicU64,
domain_stats: RwLock<HashMap<String, (u64, u64, u64)>>, // domain -> (count, sent, recv)
ips: RwLock<Vec<String>>,
#[allow(dead_code)]
session_start: u64,
}
impl LiveTrafficTracker {
pub fn new(proxy_id: String, profile_id: Option<String>) -> Self {
Self {
proxy_id,
profile_id,
bytes_sent: AtomicU64::new(0),
bytes_received: AtomicU64::new(0),
requests: AtomicU64::new(0),
domain_stats: RwLock::new(HashMap::new()),
ips: RwLock::new(Vec::new()),
session_start: current_timestamp(),
}
}
pub fn add_bytes_sent(&self, bytes: u64) {
self.bytes_sent.fetch_add(bytes, Ordering::Relaxed);
}
pub fn add_bytes_received(&self, bytes: u64) {
self.bytes_received.fetch_add(bytes, Ordering::Relaxed);
}
pub fn record_request(&self, domain: &str, bytes_sent: u64, bytes_received: u64) {
self.requests.fetch_add(1, Ordering::Relaxed);
// Also update total byte counters for HTTP requests (not tunneled)
self.bytes_sent.fetch_add(bytes_sent, Ordering::Relaxed);
self
.bytes_received
.fetch_add(bytes_received, Ordering::Relaxed);
if let Ok(mut stats) = self.domain_stats.write() {
let entry = stats.entry(domain.to_string()).or_insert((0, 0, 0));
entry.0 += 1;
entry.1 += bytes_sent;
entry.2 += bytes_received;
}
}
pub fn record_ip(&self, ip: &str) {
if let Ok(mut ips) = self.ips.write() {
if !ips.contains(&ip.to_string()) {
ips.push(ip.to_string());
}
}
}
/// Update domain-specific byte counts (called when CONNECT tunnel closes)
pub fn update_domain_bytes(&self, domain: &str, bytes_sent: u64, bytes_received: u64) {
if let Ok(mut stats) = self.domain_stats.write() {
let entry = stats.entry(domain.to_string()).or_insert((0, 0, 0));
entry.1 += bytes_sent;
entry.2 += bytes_received;
}
}
/// Get current stats snapshot
pub fn get_snapshot(&self) -> (u64, u64, u64) {
(
self.bytes_sent.load(Ordering::Relaxed),
self.bytes_received.load(Ordering::Relaxed),
self.requests.load(Ordering::Relaxed),
)
}
/// Flush current stats to disk and return the delta
pub fn flush_to_disk(&self) -> Result<(u64, u64), Box<dyn std::error::Error>> {
let bytes_sent = self.bytes_sent.swap(0, Ordering::Relaxed);
let bytes_received = self.bytes_received.swap(0, Ordering::Relaxed);
// Use profile_id as storage key if available, otherwise fall back to proxy_id
let storage_key = self
.profile_id
.clone()
.unwrap_or_else(|| self.proxy_id.clone());
// Load or create stats using the storage key
let mut stats = load_traffic_stats(&storage_key)
.unwrap_or_else(|| TrafficStats::new(self.proxy_id.clone(), self.profile_id.clone()));
// Ensure profile_id is set (in case stats were loaded from disk without it)
if stats.profile_id.is_none() && self.profile_id.is_some() {
stats.profile_id = self.profile_id.clone();
}
// Update the proxy_id to current session (for debugging/tracking)
stats.proxy_id = self.proxy_id.clone();
// Update bandwidth history
stats.record_bandwidth(bytes_sent, bytes_received);
// Update domain stats
if let Ok(mut domain_map) = self.domain_stats.write() {
for (domain, (count, sent, recv)) in domain_map.drain() {
stats.record_request(&domain, sent, recv);
// Adjust request count (record_request increments total_requests)
stats.total_requests = stats.total_requests.saturating_sub(1) + count;
}
}
// Update IPs
if let Ok(ips) = self.ips.read() {
for ip in ips.iter() {
stats.record_ip(ip);
}
}
// Save to disk
save_traffic_stats(&stats)?;
Ok((bytes_sent, bytes_received))
}
}
/// Global traffic tracker that can be accessed from connection handlers
/// Using RwLock to allow reinitialization when proxy config changes
static TRAFFIC_TRACKER: std::sync::RwLock<Option<Arc<LiveTrafficTracker>>> =
std::sync::RwLock::new(None);
/// Initialize the global traffic tracker
/// This can be called multiple times to update the tracker when proxy config changes
pub fn init_traffic_tracker(proxy_id: String, profile_id: Option<String>) {
let tracker = Arc::new(LiveTrafficTracker::new(proxy_id, profile_id));
if let Ok(mut guard) = TRAFFIC_TRACKER.write() {
*guard = Some(tracker);
}
}
/// Get the global traffic tracker
pub fn get_traffic_tracker() -> Option<Arc<LiveTrafficTracker>> {
TRAFFIC_TRACKER.read().ok().and_then(|guard| guard.clone())
}
/// Filtered traffic stats for client display (only contains data for requested period)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilteredTrafficStats {
pub profile_id: Option<String>,
pub session_start: u64,
pub last_update: u64,
pub total_bytes_sent: u64,
pub total_bytes_received: u64,
pub total_requests: u64,
/// Bandwidth history filtered to requested time period
pub bandwidth_history: Vec<BandwidthDataPoint>,
/// Period stats: bytes sent/received within the requested period
pub period_bytes_sent: u64,
pub period_bytes_received: u64,
/// Domain access statistics (always full, as it's already aggregated)
pub domains: HashMap<String, DomainAccess>,
/// Unique IPs accessed
pub unique_ips: Vec<String>,
}
/// Get traffic stats for a profile, filtered to a specific time period
/// seconds: number of seconds to include (0 = all time)
pub fn get_traffic_stats_for_period(
profile_id: &str,
seconds: u64,
) -> Option<FilteredTrafficStats> {
let stats = load_traffic_stats(profile_id)?;
let now = current_timestamp();
let cutoff = if seconds == 0 {
0 // All time
} else {
now.saturating_sub(seconds)
};
// Filter bandwidth history to requested period
let filtered_history: Vec<BandwidthDataPoint> = stats
.bandwidth_history
.iter()
.filter(|dp| dp.timestamp >= cutoff)
.cloned()
.collect();
// Calculate period totals
let period_bytes_sent: u64 = filtered_history.iter().map(|dp| dp.bytes_sent).sum();
let period_bytes_received: u64 = filtered_history.iter().map(|dp| dp.bytes_received).sum();
Some(FilteredTrafficStats {
profile_id: stats.profile_id,
session_start: stats.session_start,
last_update: stats.last_update,
total_bytes_sent: stats.total_bytes_sent,
total_bytes_received: stats.total_bytes_received,
total_requests: stats.total_requests,
bandwidth_history: filtered_history,
period_bytes_sent,
period_bytes_received,
domains: stats.domains,
unique_ips: stats.unique_ips,
})
}
/// Get lightweight traffic snapshot for a profile (for mini charts, only recent 60 seconds)
pub fn get_traffic_snapshot_for_profile(profile_id: &str) -> Option<TrafficSnapshot> {
let stats = load_traffic_stats(profile_id)?;
Some(stats.to_snapshot())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_traffic_stats_creation() {
let stats = TrafficStats::new(
"test_proxy".to_string(),
Some("test-profile-id".to_string()),
);
assert_eq!(stats.proxy_id, "test_proxy");
assert_eq!(stats.profile_id, Some("test-profile-id".to_string()));
assert_eq!(stats.total_bytes_sent, 0);
assert_eq!(stats.total_bytes_received, 0);
}
#[test]
fn test_bandwidth_recording() {
let mut stats = TrafficStats::new("test_proxy".to_string(), None);
stats.record_bandwidth(1000, 2000);
assert_eq!(stats.total_bytes_sent, 1000);
assert_eq!(stats.total_bytes_received, 2000);
assert_eq!(stats.bandwidth_history.len(), 1);
stats.record_bandwidth(500, 1000);
assert_eq!(stats.total_bytes_sent, 1500);
assert_eq!(stats.total_bytes_received, 3000);
}
#[test]
fn test_domain_recording() {
let mut stats = TrafficStats::new("test_proxy".to_string(), None);
stats.record_request("example.com", 100, 500);
stats.record_request("example.com", 200, 1000);
stats.record_request("google.com", 50, 200);
assert_eq!(stats.domains.len(), 2);
assert_eq!(stats.domains["example.com"].request_count, 2);
assert_eq!(stats.domains["example.com"].bytes_sent, 300);
assert_eq!(stats.domains["google.com"].request_count, 1);
}
#[test]
fn test_ip_recording() {
let mut stats = TrafficStats::new("test_proxy".to_string(), None);
stats.record_ip("192.168.1.1");
stats.record_ip("192.168.1.1"); // Duplicate
stats.record_ip("10.0.0.1");
assert_eq!(stats.unique_ips.len(), 2);
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.12.3",
"version": "0.13.1",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
@@ -17,7 +17,7 @@
},
"bundle": {
"active": true,
"targets": "all",
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
"category": "Productivity",
"externalBin": ["binaries/nodecar", "binaries/donut-proxy"],
"icon": [
+161 -7
View File
@@ -351,7 +351,8 @@ async fn test_multiple_proxies_simultaneously(
let mut proxy_ports = Vec::new();
// Start 3 proxies with a small delay between each to avoid race conditions
// Start 3 proxies, waiting for each to be ready before starting the next
// This avoids race conditions on macOS where processes need time to initialize
for i in 0..3 {
let output = TestUtils::execute_command(&binary_path, &["proxy", "start"]).await?;
if !output.status.success() {
@@ -376,14 +377,36 @@ async fn test_multiple_proxies_simultaneously(
println!("Proxy {} started on port {}", i + 1, local_port);
// Small delay between starting proxies to avoid resource contention
sleep(Duration::from_millis(100)).await;
// Wait for this proxy to be ready before starting the next one
// This prevents race conditions on macOS where processes need time to initialize
let mut attempts = 0;
let max_attempts = 50; // 5 seconds max (50 * 100ms)
loop {
sleep(Duration::from_millis(100)).await;
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => {
println!("Proxy {} is ready on port {}", i + 1, local_port);
break;
}
Err(_) => {
attempts += 1;
if attempts >= max_attempts {
return Err(
format!(
"Proxy {} on port {} failed to become ready after {} attempts",
i + 1,
local_port,
max_attempts
)
.into(),
);
}
}
}
}
}
// Wait for all proxies to be ready
sleep(Duration::from_millis(1000)).await;
// Verify all proxies are listening
// Verify all proxies are still listening
for (i, port) in proxy_ports.iter().enumerate() {
match TcpStream::connect(("127.0.0.1", *port)).await {
Ok(_) => {
@@ -439,6 +462,137 @@ async fn test_proxy_list() -> Result<(), Box<dyn std::error::Error + Send + Sync
Ok(())
}
/// Test traffic tracking through proxy
#[tokio::test]
#[serial]
async fn test_traffic_tracking() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
println!("Testing traffic tracking through proxy...");
// Start a proxy
let output = TestUtils::execute_command(&binary_path, &["proxy", "start"]).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!("Failed to start proxy - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy_id.clone());
println!("Proxy started on port {}", local_port);
// Wait for proxy to be ready
sleep(Duration::from_millis(500)).await;
// Make an HTTP request through the proxy
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request =
b"GET http://httpbin.org/ip HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n";
// Track bytes sent
let bytes_sent = request.len();
stream.write_all(request).await?;
// Read response
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let bytes_received = response.len();
println!(
"HTTP request completed: sent {} bytes, received {} bytes",
bytes_sent, bytes_received
);
// Wait for traffic stats to be flushed (happens every second)
sleep(Duration::from_secs(2)).await;
// Verify traffic was tracked by checking traffic stats file exists
// Note: Traffic stats are stored in the cache directory
let cache_dir = directories::BaseDirs::new()
.expect("Failed to get base directories")
.cache_dir()
.to_path_buf();
let traffic_stats_dir = cache_dir.join("DonutBrowserDev").join("traffic_stats");
let stats_file = traffic_stats_dir.join(format!("{}.json", proxy_id));
if stats_file.exists() {
let content = std::fs::read_to_string(&stats_file)?;
let stats: Value = serde_json::from_str(&content)?;
let total_sent = stats["total_bytes_sent"].as_u64().unwrap_or(0);
let total_received = stats["total_bytes_received"].as_u64().unwrap_or(0);
let total_requests = stats["total_requests"].as_u64().unwrap_or(0);
println!(
"Traffic stats recorded: sent {} bytes, received {} bytes, {} requests",
total_sent, total_received, total_requests
);
// Check if domains are being tracked
let mut domain_traffic = false;
if let Some(domains) = stats.get("domains") {
if let Some(domain_map) = domains.as_object() {
println!("Domains tracked: {}", domain_map.len());
for (domain, domain_stats) in domain_map {
println!(" - {}", domain);
// Check if any domain has traffic
if let Some(domain_obj) = domain_stats.as_object() {
let domain_sent = domain_obj
.get("bytes_sent")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let domain_recv = domain_obj
.get("bytes_received")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let domain_reqs = domain_obj
.get("request_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
println!(
" sent: {}, received: {}, requests: {}",
domain_sent, domain_recv, domain_reqs
);
if domain_sent > 0 || domain_recv > 0 || domain_reqs > 0 {
domain_traffic = true;
}
}
}
}
}
// Verify that some traffic was recorded - check either total bytes or domain traffic
assert!(
total_sent > 0 || total_received > 0 || total_requests > 0 || domain_traffic,
"Traffic stats should record some activity (sent: {}, received: {}, requests: {})",
total_sent,
total_received,
total_requests
);
println!("Traffic tracking test passed!");
} else {
println!("Warning: Traffic stats file not found at {:?}", stats_file);
// This is not necessarily a failure - the file may not have been created yet
// The important thing is that the proxy is working
}
// Cleanup
tracker.cleanup_all().await;
// Clean up the traffic stats file
if stats_file.exists() {
let _ = std::fs::remove_file(&stats_file);
}
Ok(())
}
/// Test proxy stop
#[tokio::test]
#[serial]
+47 -3
View File
@@ -15,6 +15,7 @@ import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { SettingsDialog } from "@/components/settings-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
@@ -62,7 +63,11 @@ export default function Home() {
error: groupsError,
} = useGroupEvents();
const { isLoading: proxiesLoading, error: proxiesError } = useProxyEvents();
const {
storedProxies,
isLoading: proxiesLoading,
error: proxiesError,
} = useProxyEvents();
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
@@ -75,10 +80,15 @@ export default function Home() {
useState(false);
const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] =
useState(false);
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
useState(false);
const [selectedGroupId, setSelectedGroupId] = useState<string>("default");
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
string[]
>([]);
const [selectedProfilesForProxy, setSelectedProfilesForProxy] = useState<
string[]
>([]);
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState<string>("");
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
@@ -559,12 +569,29 @@ export default function Home() {
setSelectedProfiles([]);
}, [selectedProfiles, handleAssignProfilesToGroup]);
const handleAssignProfilesToProxy = useCallback((profileIds: string[]) => {
setSelectedProfilesForProxy(profileIds);
setProxyAssignmentDialogOpen(true);
}, []);
const handleBulkProxyAssignment = useCallback(() => {
if (selectedProfiles.length === 0) return;
handleAssignProfilesToProxy(selectedProfiles);
setSelectedProfiles([]);
}, [selectedProfiles, handleAssignProfilesToProxy]);
const handleGroupAssignmentComplete = useCallback(async () => {
// No need to manually reload - useProfileEvents will handle the update
setGroupAssignmentDialogOpen(false);
setSelectedProfilesForGroup([]);
}, []);
const handleProxyAssignmentComplete = useCallback(async () => {
// No need to manually reload - useProfileEvents will handle the update
setProxyAssignmentDialogOpen(false);
setSelectedProfilesForProxy([]);
}, []);
const handleGroupManagementComplete = useCallback(async () => {
// No need to manually reload - useProfileEvents will handle the update
}, []);
@@ -676,8 +703,8 @@ export default function Home() {
// Search in profile name
if (profile.name.toLowerCase().includes(query)) return true;
// Search in browser name
if (profile.browser.toLowerCase().includes(query)) return true;
// Search in note
if (profile.note?.toLowerCase().includes(query)) return true;
// Search in tags
if (profile.tags?.some((tag) => tag.toLowerCase().includes(query)))
@@ -730,6 +757,7 @@ export default function Home() {
onSelectedProfilesChange={setSelectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onBulkProxyAssignment={handleBulkProxyAssignment}
/>
</div>
</main>
@@ -807,6 +835,11 @@ export default function Home() {
}}
profile={currentProfileForCamoufoxConfig}
onSave={handleSaveCamoufoxConfig}
isRunning={
currentProfileForCamoufoxConfig
? runningProfiles.has(currentProfileForCamoufoxConfig.id)
: false
}
/>
<GroupManagementDialog
@@ -827,6 +860,17 @@ export default function Home() {
profiles={profiles}
/>
<ProxyAssignmentDialog
isOpen={proxyAssignmentDialogOpen}
onClose={() => {
setProxyAssignmentDialogOpen(false);
}}
selectedProfiles={selectedProfilesForProxy}
onAssignmentComplete={handleProxyAssignmentComplete}
profiles={profiles}
storedProxies={storedProxies}
/>
<DeleteConfirmationDialog
isOpen={showBulkDeleteConfirmation}
onClose={() => setShowBulkDeleteConfirmation(false)}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import * as React from "react";
import { Area, AreaChart, ResponsiveContainer } from "recharts";
import { cn } from "@/lib/utils";
import type { BandwidthDataPoint } from "@/types";
interface BandwidthMiniChartProps {
data: BandwidthDataPoint[];
currentBandwidth?: number;
onClick?: () => void;
className?: string;
}
export function BandwidthMiniChart({
data,
currentBandwidth: externalBandwidth,
onClick,
className,
}: BandwidthMiniChartProps) {
// Transform data for the chart - combine sent and received for total bandwidth
const chartData = React.useMemo(() => {
// Fill in missing seconds with zeros for smooth chart
if (data.length === 0) {
// Create 60 seconds of zero data for the past minute
const now = Math.floor(Date.now() / 1000);
return Array.from({ length: 60 }, (_, i) => ({
time: now - (59 - i),
bandwidth: 0,
}));
}
const now = Math.floor(Date.now() / 1000);
const result: { time: number; bandwidth: number }[] = [];
// Get the last 60 seconds
for (let i = 59; i >= 0; i--) {
const targetTime = now - i;
const point = data.find((d) => d.timestamp === targetTime);
result.push({
time: targetTime,
bandwidth: point ? point.bytes_sent + point.bytes_received : 0,
});
}
return result;
}, [data]);
// Find max value for scaling
const _maxBandwidth = React.useMemo(() => {
const max = Math.max(...chartData.map((d) => d.bandwidth), 1);
return max;
}, [chartData]);
// Use external bandwidth if provided, otherwise calculate from last data point
const currentBandwidth =
externalBandwidth ?? chartData[chartData.length - 1]?.bandwidth ?? 0;
// Format bytes to human readable
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B/s";
if (bytes < 1024) return `${bytes} B/s`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB/s`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB/s`;
};
return (
<button
type="button"
onClick={onClick}
className={cn(
"relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors min-w-[120px] border-none bg-transparent",
className,
)}
>
<div className="flex-1 h-3">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="bandwidthGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-1)"
stopOpacity={0.6}
/>
<stop
offset="100%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="bandwidth"
stroke="var(--chart-1)"
strokeWidth={1}
fill="url(#bandwidthGradient)"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap min-w-[60px] text-right">
{formatBytes(currentBandwidth)}
</span>
</button>
);
}
+33 -15
View File
@@ -10,7 +10,16 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
import type { BrowserProfile, CamoufoxConfig, CamoufoxOS } from "@/types";
const getCurrentOS = (): CamoufoxOS => {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
if (platform.includes("win")) return "windows";
if (platform.includes("mac")) return "macos";
return "linux";
};
import { LoadingButton } from "./loading-button";
import { RippleButton } from "./ui/ripple";
@@ -19,6 +28,7 @@ interface CamoufoxConfigDialogProps {
onClose: () => void;
profile: BrowserProfile | null;
onSave: (profile: BrowserProfile, config: CamoufoxConfig) => Promise<void>;
isRunning?: boolean;
}
export function CamoufoxConfigDialog({
@@ -26,10 +36,12 @@ export function CamoufoxConfigDialog({
onClose,
profile,
onSave,
isRunning = false,
}: CamoufoxConfigDialogProps) {
const [config, setConfig] = useState<CamoufoxConfig>({
const [config, setConfig] = useState<CamoufoxConfig>(() => ({
geoip: true,
});
os: getCurrentOS(),
}));
const [isSaving, setIsSaving] = useState(false);
// Initialize config when profile changes
@@ -38,6 +50,7 @@ export function CamoufoxConfigDialog({
setConfig(
profile.camoufox_config || {
geoip: true,
os: getCurrentOS(),
},
);
}
@@ -86,6 +99,7 @@ export function CamoufoxConfigDialog({
setConfig(
profile.camoufox_config || {
geoip: true,
os: getCurrentOS(),
},
);
}
@@ -101,33 +115,37 @@ export function CamoufoxConfigDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogHeader className="shrink-0">
<DialogTitle>
Configure Fingerprint Settings - {profile.name}
{isRunning ? "View" : "Configure"} Fingerprint Settings -{" "}
{profile.name}
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 h-[320px]">
<ScrollArea className="flex-1 h-[300px]">
<div className="py-4">
<SharedCamoufoxConfigForm
config={config}
onConfigChange={updateConfig}
forceAdvanced={true}
readOnly={isRunning}
/>
</div>
</ScrollArea>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<DialogFooter className="shrink-0 pt-4 border-t">
<RippleButton variant="outline" onClick={handleClose}>
Cancel
{isRunning ? "Close" : "Cancel"}
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={handleSave}
disabled={isSaving}
>
Save
</LoadingButton>
{!isRunning && (
<LoadingButton
isLoading={isSaving}
onClick={handleSave}
disabled={isSaving}
>
Save
</LoadingButton>
)}
</DialogFooter>
</DialogContent>
</Dialog>
+14 -3
View File
@@ -29,7 +29,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserReleaseTypes, CamoufoxConfig } from "@/types";
import type { BrowserReleaseTypes, CamoufoxConfig, CamoufoxOS } from "@/types";
const getCurrentOS = (): CamoufoxOS => {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
if (platform.includes("win")) return "windows";
if (platform.includes("mac")) return "macos";
return "linux";
};
import { RippleButton } from "./ui/ripple";
type BrowserTypeString =
@@ -111,9 +120,10 @@ export function CreateProfileDialog({
const [selectedProxyId, setSelectedProxyId] = useState<string>();
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
geoip: true, // Default to automatic geoip
});
os: getCurrentOS(), // Default to current OS
}));
// Handle browser selection from the initial screen
const handleBrowserSelect = (browser: BrowserTypeString) => {
@@ -379,6 +389,7 @@ export function CreateProfileDialog({
setReleaseTypes({});
setCamoufoxConfig({
geoip: true, // Reset to automatic geoip
os: getCurrentOS(), // Reset to current OS
});
onClose();
};
+6 -1
View File
@@ -142,7 +142,12 @@ const HomeHeader = ({
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Create a new profile</TooltipContent>
<TooltipContent
arrowOffset={-8}
style={{ transform: "translateX(-8px)" }}
>
Create a new profile
</TooltipContent>
</Tooltip>
</div>
</div>
+447 -90
View File
@@ -13,6 +13,7 @@ import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import type { Dispatch, SetStateAction } from "react";
import * as React from "react";
import { FiWifi } from "react-icons/fi";
import { IoEllipsisHorizontal } from "react-icons/io5";
import {
LuCheck,
@@ -68,15 +69,21 @@ import {
} from "@/lib/browser-utils";
import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, ProxyCheckResult, StoredProxy } from "@/types";
import type {
BrowserProfile,
ProxyCheckResult,
StoredProxy,
TrafficSnapshot,
} from "@/types";
import { BandwidthMiniChart } from "./bandwidth-mini-chart";
import {
DataTableActionBar,
DataTableActionBarAction,
DataTableActionBarSelection,
} from "./data-table-action-bar";
import { LoadingButton } from "./loading-button";
import MultipleSelector, { type Option } from "./multiple-selector";
import { ProxyCheckButton } from "./proxy-check-button";
import { TrafficDetailsDialog } from "./traffic-details-dialog";
import { Input } from "./ui/input";
import { RippleButton } from "./ui/ripple";
@@ -103,6 +110,14 @@ type TableMeta = {
React.SetStateAction<Record<string, string[]>>
>;
// Note editor state
noteOverrides: Record<string, string | null>;
openNoteEditorFor: string | null;
setOpenNoteEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
setNoteOverrides: React.Dispatch<
React.SetStateAction<Record<string, string | null>>
>;
// Proxy selector state
openProxySelectorFor: string | null;
setOpenProxySelectorFor: React.Dispatch<React.SetStateAction<string | null>>;
@@ -142,6 +157,10 @@ type TableMeta = {
// Overflow actions
onAssignProfilesToGroup?: (profileIds: string[]) => void;
onConfigureCamoufox?: (profile: BrowserProfile) => void;
// Traffic snapshots (lightweight real-time data)
trafficSnapshots: Record<string, TrafficSnapshot>;
onOpenTrafficDialog?: (profileId: string) => void;
};
const TagsCell = React.memo<{
@@ -402,6 +421,243 @@ const TagsCell = React.memo<{
TagsCell.displayName = "TagsCell";
const NonHoverableTooltip = React.memo<{
children: React.ReactNode;
content: React.ReactNode;
sideOffset?: number;
alignOffset?: number;
horizontalOffset?: number;
}>(
({
children,
content,
sideOffset = 4,
alignOffset = 0,
horizontalOffset = 0,
}) => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
<TooltipTrigger
asChild
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
{children}
</TooltipTrigger>
<TooltipContent
sideOffset={sideOffset}
alignOffset={alignOffset}
arrowOffset={horizontalOffset}
onPointerEnter={(e) => e.preventDefault()}
onPointerLeave={() => setIsOpen(false)}
className="pointer-events-none"
style={
horizontalOffset !== 0
? { transform: `translateX(${horizontalOffset}px)` }
: undefined
}
>
{content}
</TooltipContent>
</Tooltip>
);
},
);
NonHoverableTooltip.displayName = "NonHoverableTooltip";
const NoteCell = React.memo<{
profile: BrowserProfile;
isDisabled: boolean;
noteOverrides: Record<string, string | null>;
openNoteEditorFor: string | null;
setOpenNoteEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
setNoteOverrides: React.Dispatch<
React.SetStateAction<Record<string, string | null>>
>;
}>(
({
profile,
isDisabled,
noteOverrides,
openNoteEditorFor,
setOpenNoteEditorFor,
setNoteOverrides,
}) => {
const effectiveNote: string | null = Object.hasOwn(
noteOverrides,
profile.id,
)
? noteOverrides[profile.id]
: (profile.note ?? null);
const onNoteChange = React.useCallback(
async (newNote: string | null) => {
const trimmedNote = newNote?.trim() || null;
setNoteOverrides((prev) => ({ ...prev, [profile.id]: trimmedNote }));
try {
await invoke<BrowserProfile>("update_profile_note", {
profileId: profile.id,
note: trimmedNote,
});
} catch (error) {
console.error("Failed to update note:", error);
}
},
[profile.id, setNoteOverrides],
);
const editorRef = React.useRef<HTMLDivElement | null>(null);
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
const [noteValue, setNoteValue] = React.useState(effectiveNote || "");
// Update local state when effective note changes (from outside)
React.useEffect(() => {
if (openNoteEditorFor !== profile.id) {
setNoteValue(effectiveNote || "");
}
}, [effectiveNote, openNoteEditorFor, profile.id]);
// Auto-resize textarea on open
React.useEffect(() => {
if (openNoteEditorFor === profile.id && textareaRef.current) {
const textarea = textareaRef.current;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}, [openNoteEditorFor, profile.id]);
const handleTextareaChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setNoteValue(newValue);
// Auto-resize
const textarea = e.target;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
},
[],
);
React.useEffect(() => {
if (openNoteEditorFor !== profile.id) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (
editorRef.current &&
target &&
!editorRef.current.contains(target)
) {
const currentValue = textareaRef.current?.value || "";
void onNoteChange(currentValue);
setOpenNoteEditorFor(null);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [openNoteEditorFor, profile.id, setOpenNoteEditorFor, onNoteChange]);
React.useEffect(() => {
if (openNoteEditorFor === profile.id && textareaRef.current) {
textareaRef.current.focus();
// Move cursor to end
const len = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(len, len);
}
}, [openNoteEditorFor, profile.id]);
const displayNote = effectiveNote || "";
const trimmedNote =
displayNote.length > 12 ? `${displayNote.slice(0, 12)}...` : displayNote;
const showTooltip = displayNote.length > 12 || displayNote.length > 0;
if (openNoteEditorFor !== profile.id) {
return (
<div className="w-24 min-h-6">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"flex items-start px-2 py-1 min-h-6 w-full bg-transparent rounded border-none text-left",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
)}
onClick={() => {
if (!isDisabled) {
setNoteValue(effectiveNote || "");
setOpenNoteEditorFor(profile.id);
}
}}
>
<span
className={cn(
"text-sm wrap-break-word",
!effectiveNote && "text-muted-foreground",
)}
>
{effectiveNote ? trimmedNote : "No Note"}
</span>
</button>
</TooltipTrigger>
{showTooltip && (
<TooltipContent className="max-w-[320px]">
<p className="whitespace-pre-wrap wrap-break-word">
{effectiveNote || "No Note"}
</p>
</TooltipContent>
)}
</Tooltip>
</div>
);
}
return (
<div
className={cn(
"w-24 relative",
isDisabled && "opacity-60 pointer-events-none",
)}
>
<div
ref={editorRef}
className="absolute -top-[15px] -left-px z-50 w-60 min-h-6 bg-popover rounded-md shadow-md border"
>
<textarea
ref={textareaRef}
value={noteValue}
onChange={handleTextareaChange}
onKeyDown={(e) => {
if (e.key === "Escape") {
setNoteValue(effectiveNote || "");
setOpenNoteEditorFor(null);
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
void onNoteChange(noteValue);
setOpenNoteEditorFor(null);
}
}}
onBlur={() => {
void onNoteChange(noteValue);
setOpenNoteEditorFor(null);
}}
placeholder="Add a note..."
className="w-full min-h-6 max-h-[200px] px-2 py-1 text-sm bg-transparent border-0 resize-none focus:outline-none focus:ring-0"
style={{
overflow: "auto",
}}
rows={1}
/>
</div>
</div>
);
},
);
NoteCell.displayName = "NoteCell";
interface ProfilesDataTableProps {
profiles: BrowserProfile[];
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
@@ -418,6 +674,7 @@ interface ProfilesDataTableProps {
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
onBulkDelete?: () => void;
onBulkGroupAssignment?: () => void;
onBulkProxyAssignment?: () => void;
}
export function ProfilesDataTable({
@@ -434,6 +691,7 @@ export function ProfilesDataTable({
onSelectedProfilesChange,
onBulkDelete,
onBulkGroupAssignment,
onBulkProxyAssignment,
}: ProfilesDataTableProps) {
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
const [sorting, setSorting] = React.useState<SortingState>([]);
@@ -526,6 +784,19 @@ export function ProfilesDataTable({
const [proxyCheckResults, setProxyCheckResults] = React.useState<
Record<string, ProxyCheckResult>
>({});
const [noteOverrides, setNoteOverrides] = React.useState<
Record<string, string | null>
>({});
const [openNoteEditorFor, setOpenNoteEditorFor] = React.useState<
string | null
>(null);
const [trafficSnapshots, setTrafficSnapshots] = React.useState<
Record<string, TrafficSnapshot>
>({});
const [trafficDialogProfile, setTrafficDialogProfile] = React.useState<{
id: string;
name?: string;
} | null>(null);
// Load cached check results for proxies
React.useEffect(() => {
@@ -594,6 +865,42 @@ export function ProfilesDataTable({
stoppingProfiles,
);
// Fetch traffic snapshots for running profiles (lightweight, real-time data)
// Using runningProfiles.size as dependency to avoid Set reference comparison issues
const runningCount = runningProfiles.size;
React.useEffect(() => {
if (!browserState.isClient) return;
if (runningCount === 0) {
setTrafficSnapshots({});
return;
}
const fetchTrafficSnapshots = async () => {
try {
const allSnapshots = await invoke<TrafficSnapshot[]>(
"get_all_traffic_snapshots",
);
const newSnapshots: Record<string, TrafficSnapshot> = {};
for (const snapshot of allSnapshots) {
if (snapshot.profile_id) {
const existing = newSnapshots[snapshot.profile_id];
if (!existing || snapshot.last_update > existing.last_update) {
newSnapshots[snapshot.profile_id] = snapshot;
}
}
}
setTrafficSnapshots(newSnapshots);
} catch (error) {
console.error("Failed to fetch traffic snapshots:", error);
}
};
void fetchTrafficSnapshots();
const interval = setInterval(fetchTrafficSnapshots, 1000);
return () => clearInterval(interval);
}, [browserState.isClient, runningCount]);
// Clear launching/stopping spinners when backend reports running status changes
React.useEffect(() => {
if (!browserState.isClient) return;
@@ -892,6 +1199,12 @@ export function ProfilesDataTable({
setOpenTagsEditorFor,
setTagsOverrides,
// Note editor state
noteOverrides,
openNoteEditorFor,
setOpenNoteEditorFor,
setNoteOverrides,
// Proxy selector state
openProxySelectorFor,
setOpenProxySelectorFor,
@@ -926,6 +1239,13 @@ export function ProfilesDataTable({
// Overflow actions
onAssignProfilesToGroup,
onConfigureCamoufox,
// Traffic snapshots (lightweight real-time data)
trafficSnapshots,
onOpenTrafficDialog: (profileId: string) => {
const profile = profiles.find((p) => p.id === profileId);
setTrafficDialogProfile({ id: profileId, name: profile?.name });
},
}),
[
selectedProfiles,
@@ -940,6 +1260,8 @@ export function ProfilesDataTable({
tagsOverrides,
allTags,
openTagsEditorFor,
noteOverrides,
openNoteEditorFor,
openProxySelectorFor,
proxyOverrides,
storedProxies,
@@ -953,6 +1275,8 @@ export function ProfilesDataTable({
profileToRename,
newProfileName,
isRenamingSaving,
trafficSnapshots,
profiles,
renameError,
onKillProfile,
onLaunchProfile,
@@ -1021,37 +1345,51 @@ export function ProfilesDataTable({
);
}
const browserName = getBrowserDisplayName(browser);
if (meta.showCheckboxes || isSelected) {
return (
<span className="flex justify-center items-center w-4 h-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) =>
meta.handleCheckboxChange(profile.id, !!value)
}
aria-label="Select row"
className="w-4 h-4"
/>
</span>
<NonHoverableTooltip
content={<p>{browserName}</p>}
sideOffset={4}
horizontalOffset={8}
>
<span className="flex justify-center items-center w-4 h-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) =>
meta.handleCheckboxChange(profile.id, !!value)
}
aria-label="Select row"
className="w-4 h-4"
/>
</span>
</NonHoverableTooltip>
);
}
return (
<span className="flex relative justify-center items-center w-4 h-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
onClick={() => meta.handleIconClick(profile.id)}
aria-label="Select profile"
>
<span className="w-4 h-4 group">
{IconComponent && (
<IconComponent className="w-4 h-4 group-hover:hidden" />
)}
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
</span>
</button>
</span>
<NonHoverableTooltip
content={<p>{browserName}</p>}
sideOffset={4}
horizontalOffset={8}
>
<span className="flex relative justify-center items-center w-4 h-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
onClick={() => meta.handleIconClick(profile.id)}
aria-label="Select profile"
>
<span className="w-4 h-4 group">
{IconComponent && (
<IconComponent className="w-4 h-4 group-hover:hidden" />
)}
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
</span>
</button>
</span>
</NonHoverableTooltip>
);
},
enableSorting: false,
@@ -1172,11 +1510,6 @@ export function ProfilesDataTable({
const isEditing = meta.profileToRename?.id === profile.id;
if (isEditing) {
const isSaveDisabled =
meta.isRenamingSaving ||
meta.newProfileName.trim().length === 0 ||
meta.newProfileName.trim() === profile.name;
return (
<div
ref={renameContainerRef}
@@ -1190,7 +1523,9 @@ export function ProfilesDataTable({
if (meta.renameError) meta.setRenameError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (e.key === "Enter" && !(e.metaKey || e.ctrlKey)) {
void meta.handleRename();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
void meta.handleRename();
} else if (e.key === "Escape") {
meta.setProfileToRename(null);
@@ -1198,20 +1533,20 @@ export function ProfilesDataTable({
meta.setRenameError(null);
}
}}
onBlur={() => {
if (
meta.newProfileName.trim().length > 0 &&
meta.newProfileName.trim() !== profile.name
) {
void meta.handleRename();
} else {
meta.setProfileToRename(null);
meta.setNewProfileName("");
meta.setRenameError(null);
}
}}
className="w-30 h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0"
/>
<div className="flex absolute right-0 top-full z-50 gap-1 translate-y-[30%] opacity-100 bg-black rounded-md">
<LoadingButton
isLoading={meta.isRenamingSaving}
size="sm"
variant="default"
disabled={isSaveDisabled}
className="cursor-pointer [[disabled]]:bg-primary/80"
onClick={() => void meta.handleRename()}
>
Save
</LoadingButton>
</div>
</div>
);
}
@@ -1295,51 +1630,28 @@ export function ProfilesDataTable({
},
},
{
accessorKey: "browser",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
>
Browser
{column.getIsSorted() === "asc" ? (
<LuChevronUp className="ml-2 w-4 h-4" />
) : column.getIsSorted() === "desc" ? (
<LuChevronDown className="ml-2 w-4 h-4" />
) : null}
</Button>
);
},
cell: ({ row }) => {
const browser: string = row.getValue("browser");
const name = getBrowserDisplayName(browser);
if (name.length < 14) {
return (
<div className="flex items-center">
<span>{name}</span>
</div>
);
}
id: "note",
header: "Note",
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isBrowserUpdating = meta.isUpdating(profile.browser);
const isDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
return (
<Tooltip>
<TooltipTrigger asChild>
<span>{trimName(name, 14)}</span>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
);
},
enableSorting: true,
sortingFn: (rowA, rowB, columnId) => {
const browserA: string = rowA.getValue(columnId);
const browserB: string = rowB.getValue(columnId);
return getBrowserDisplayName(browserA).localeCompare(
getBrowserDisplayName(browserB),
<NoteCell
profile={profile}
isDisabled={isDisabled}
noteOverrides={meta.noteOverrides || {}}
openNoteEditorFor={meta.openNoteEditorFor || null}
setOpenNoteEditorFor={meta.setOpenNoteEditorFor}
setNoteOverrides={meta.setNoteOverrides}
/>
);
},
},
@@ -1380,6 +1692,28 @@ export function ProfilesDataTable({
: null;
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
// When profile is running, show bandwidth chart instead of proxy selector
if (isRunning && meta.trafficSnapshots) {
// Find the traffic snapshot for this profile by matching profile_id
const snapshot = meta.trafficSnapshots[profile.id];
// Create a new array reference to ensure React detects changes
const bandwidthData = snapshot?.recent_bandwidth
? [...snapshot.recent_bandwidth]
: [];
const currentBandwidth =
(snapshot?.current_bytes_sent || 0) +
(snapshot?.current_bytes_received || 0);
return (
<BandwidthMiniChart
key={`${profile.id}-${snapshot?.last_update || 0}-${bandwidthData.length}`}
data={bandwidthData}
currentBandwidth={currentBandwidth}
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
/>
);
}
if (profile.browser === "tor-browser") {
return (
<Tooltip>
@@ -1542,6 +1876,13 @@ export function ProfilesDataTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
meta.onOpenTrafficDialog?.(profile.id);
}}
>
View Network
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
meta.onAssignProfilesToGroup?.([profile.id]);
@@ -1556,9 +1897,8 @@ export function ProfilesDataTable({
onClick={() => {
meta.onConfigureCamoufox?.(profile);
}}
disabled={isDisabled}
>
Configure Fingerprint
Change Fingerprint
</DropdownMenuItem>
)}
<DropdownMenuItem
@@ -1683,6 +2023,15 @@ export function ProfilesDataTable({
<LuUsers />
</DataTableActionBarAction>
)}
{onBulkProxyAssignment && (
<DataTableActionBarAction
tooltip="Assign Proxy"
onClick={onBulkProxyAssignment}
size="icon"
>
<FiWifi />
</DataTableActionBarAction>
)}
{onBulkDelete && (
<DataTableActionBarAction
tooltip="Delete"
@@ -1695,6 +2044,14 @@ export function ProfilesDataTable({
</DataTableActionBarAction>
)}
</DataTableActionBar>
{trafficDialogProfile && (
<TrafficDetailsDialog
isOpen={trafficDialogProfile !== null}
onClose={() => setTrafficDialogProfile(null)}
profileId={trafficDialogProfile.id}
profileName={trafficDialogProfile.name}
/>
)}
</>
);
}
+5 -23
View File
@@ -2,8 +2,6 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { LuCopy } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import {
@@ -31,6 +29,7 @@ import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserProfile } from "@/types";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
import { RippleButton } from "./ui/ripple";
interface ProfileSelectorDialogProps {
@@ -122,18 +121,6 @@ export function ProfileSelectorDialog({
onClose();
}, [onClose]);
const handleCopyUrl = useCallback(async () => {
if (!url) return;
try {
await navigator.clipboard.writeText(url);
toast.success("URL copied to clipboard!");
} catch (error) {
console.error("Failed to copy URL:", error);
toast.error("Failed to copy URL to clipboard");
}
}, [url]);
const selectedProfileData = profiles.find((p) => p.name === selectedProfile);
// Check if the selected profile can be used for opening links
@@ -186,15 +173,10 @@ export function ProfileSelectorDialog({
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label className="text-sm font-medium">Opening URL:</Label>
<RippleButton
variant="outline"
size="sm"
onClick={() => void handleCopyUrl()}
className="flex gap-2 items-center"
>
<LuCopy className="w-3 h-3" />
Copy
</RippleButton>
<CopyToClipboard
text={url}
successMessage="URL copied to clipboard!"
/>
</div>
<div className="p-2 text-sm break-all rounded bg-muted">
{url}
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BrowserProfile, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyAssignmentDialogProps {
isOpen: boolean;
onClose: () => void;
selectedProfiles: string[];
onAssignmentComplete: () => void;
profiles?: BrowserProfile[];
storedProxies?: StoredProxy[];
}
export function ProxyAssignmentDialog({
isOpen,
onClose,
selectedProfiles,
onAssignmentComplete,
profiles = [],
storedProxies = [],
}: ProxyAssignmentDialogProps) {
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(null);
const [isAssigning, setIsAssigning] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleAssign = useCallback(async () => {
setIsAssigning(true);
setError(null);
try {
// Filter out TOR browser profiles as they don't support proxies
const validProfiles = selectedProfiles.filter((profileId) => {
const profile = profiles.find((p) => p.id === profileId);
return profile && profile.browser !== "tor-browser";
});
if (validProfiles.length === 0) {
setError("No valid profiles selected.");
setIsAssigning(false);
return;
}
// Update each profile's proxy sequentially to avoid file locking issues
for (const profileId of validProfiles) {
await invoke("update_profile_proxy", {
profileId,
proxyId: selectedProxyId,
});
}
// Notify other parts of the app so usage counts and lists refresh
await emit("profile-updated");
onAssignmentComplete();
onClose();
} catch (err) {
console.error("Failed to assign proxies to profiles:", err);
const errorMessage =
err instanceof Error
? err.message
: "Failed to assign proxies to profiles";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setIsAssigning(false);
}
}, [
selectedProfiles,
selectedProxyId,
profiles,
onAssignmentComplete,
onClose,
]);
useEffect(() => {
if (isOpen) {
setSelectedProxyId(null);
setError(null);
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Assign Proxy</DialogTitle>
<DialogDescription>
Assign a proxy to {selectedProfiles.length} selected profile(s).
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Selected Profiles:</Label>
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
<ul className="text-sm space-y-1">
{selectedProfiles.map((profileId) => {
const profile = profiles.find(
(p: BrowserProfile) => p.id === profileId,
);
const displayName = profile ? profile.name : profileId;
const isTorBrowser = profile?.browser === "tor-browser";
return (
<li key={profileId} className="truncate">
{displayName}
{isTorBrowser && (
<span className="ml-2 text-xs text-muted-foreground">
(TOR - no proxy support)
</span>
)}
</li>
);
})}
</ul>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-select">Assign Proxy:</Label>
<Select
value={selectedProxyId || "none"}
onValueChange={(value) => {
setSelectedProxyId(value === "none" ? null : value);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a proxy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No Proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
</div>
<DialogFooter>
<RippleButton
variant="outline"
onClick={onClose}
disabled={isAssigning}
>
Cancel
</RippleButton>
<LoadingButton
isLoading={isAssigning}
onClick={() => void handleAssign()}
>
Assign
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+7 -10
View File
@@ -46,6 +46,7 @@ import {
THEMES,
} from "@/lib/themes";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
@@ -267,6 +268,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
setIsClearingCache(true);
try {
await invoke("clear_all_version_cache_and_refetch");
// Also clear traffic stats cache
await invoke("clear_all_traffic_stats");
// Don't show immediate success toast - let the version update progress events handle it
} catch (error) {
console.error("Failed to clear cache:", error);
@@ -839,16 +842,10 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
readOnly
className="flex-1 px-3 py-2 font-mono text-sm rounded-md border bg-muted"
/>
<RippleButton
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(settings.api_token || "");
showSuccessToast("API token copied to clipboard");
}}
>
Copy
</RippleButton>
<CopyToClipboard
text={settings.api_token || ""}
successMessage="API token copied to clipboard"
/>
</div>
<p className="text-xs text-muted-foreground">
Include this token in the Authorization header as "Bearer{" "}
+120 -10
View File
@@ -15,7 +15,11 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import type { CamoufoxConfig, CamoufoxFingerprintConfig } from "@/types";
import type {
CamoufoxConfig,
CamoufoxFingerprintConfig,
CamoufoxOS,
} from "@/types";
interface SharedCamoufoxConfigFormProps {
config: CamoufoxConfig;
@@ -23,6 +27,7 @@ interface SharedCamoufoxConfigFormProps {
className?: string;
isCreating?: boolean; // Flag to indicate if this is for creating a new profile
forceAdvanced?: boolean; // Force advanced mode (for editing)
readOnly?: boolean; // Flag to indicate if the form should be read-only
}
// Determine if fingerprint editing should be disabled
@@ -30,14 +35,36 @@ const isFingerprintEditingDisabled = (config: CamoufoxConfig): boolean => {
return config.randomize_fingerprint_on_launch === true;
};
// Detect the current operating system
const getCurrentOS = (): CamoufoxOS => {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
if (platform.includes("win")) return "windows";
if (platform.includes("mac")) return "macos";
return "linux";
};
// OS display labels
const osLabels: Record<CamoufoxOS, string> = {
windows: "Windows",
macos: "macOS",
linux: "Linux",
};
// Component for editing nested objects like webGl:parameters
interface ObjectEditorProps {
value: Record<string, unknown> | undefined;
onChange: (value: Record<string, unknown> | undefined) => void;
title: string;
readOnly?: boolean;
}
function ObjectEditor({ value, onChange, title }: ObjectEditorProps) {
function ObjectEditor({
value,
onChange,
title,
readOnly = false,
}: ObjectEditorProps) {
const [jsonString, setJsonString] = useState("");
useEffect(() => {
@@ -45,6 +72,7 @@ function ObjectEditor({ value, onChange, title }: ObjectEditorProps) {
}, [value]);
const handleChange = (newValue: string) => {
if (readOnly) return;
setJsonString(newValue);
try {
if (newValue.trim() === "" || newValue.trim() === "{}") {
@@ -75,6 +103,7 @@ function ObjectEditor({ value, onChange, title }: ObjectEditorProps) {
placeholder={`Enter ${title} as JSON`}
className="font-mono text-sm"
rows={6}
disabled={readOnly}
/>
</div>
);
@@ -86,12 +115,18 @@ export function SharedCamoufoxConfigForm({
className = "",
isCreating = false,
forceAdvanced = false,
readOnly = false,
}: SharedCamoufoxConfigFormProps) {
const [activeTab, setActiveTab] = useState(
forceAdvanced ? "manual" : "automatic",
);
const [fingerprintConfig, setFingerprintConfig] =
useState<CamoufoxFingerprintConfig>({});
const [currentOS] = useState<CamoufoxOS>(getCurrentOS);
// Get selected OS (defaults to current OS)
const selectedOS = config.os || currentOS;
const isOSDifferent = selectedOS !== currentOS;
// Set screen resolution to user's screen size when creating a new profile
useEffect(() => {
@@ -174,10 +209,39 @@ export function SharedCamoufoxConfigForm({
}
};
const isEditingDisabled = isFingerprintEditingDisabled(config);
const isEditingDisabled = isFingerprintEditingDisabled(config) || readOnly;
const renderAdvancedForm = () => (
<div className="space-y-6">
{/* Operating System Selection */}
<div className="space-y-3">
<Label>Operating System Fingerprint</Label>
<Select
value={selectedOS}
onValueChange={(value: CamoufoxOS) => onConfigChange("os", value)}
disabled={readOnly}
>
<SelectTrigger>
<SelectValue placeholder="Select operating system" />
</SelectTrigger>
<SelectContent>
<SelectItem value="windows">{osLabels.windows}</SelectItem>
<SelectItem value="macos">{osLabels.macos}</SelectItem>
<SelectItem value="linux">{osLabels.linux}</SelectItem>
</SelectContent>
</Select>
{isOSDifferent && (
<Alert className="border-yellow-500/50 bg-yellow-500/10">
<AlertDescription className="text-yellow-600 dark:text-yellow-400">
Warning: Selecting an OS different from your current system (
{osLabels[currentOS]}) increases the risk of detection. Websites
can detect mismatches between your fingerprint and actual system
behavior.
</AlertDescription>
</Alert>
)}
</div>
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
@@ -187,6 +251,7 @@ export function SharedCamoufoxConfigForm({
onCheckedChange={(checked) =>
onConfigChange("randomize_fingerprint_on_launch", checked)
}
disabled={readOnly}
/>
<Label htmlFor="randomize-fingerprint" className="font-medium">
Generate random fingerprint on every launch
@@ -201,9 +266,9 @@ export function SharedCamoufoxConfigForm({
{isEditingDisabled ? (
<Alert>
<AlertDescription>
Fingerprint editing is disabled because random fingerprint
generation is enabled. Disable the option above to manually edit the
fingerprint configuration.
{readOnly
? "Fingerprint editing is disabled because the profile is currently running. Stop the profile to make changes."
: "Fingerprint editing is disabled because random fingerprint generation is enabled. Disable the option above to manually edit the fingerprint configuration."}
</AlertDescription>
</Alert>
) : (
@@ -727,6 +792,7 @@ export function SharedCamoufoxConfigForm({
updateFingerprintConfig("webGl:parameters", value)
}
title="WebGL Parameters"
readOnly={readOnly}
/>
</div>
@@ -743,6 +809,7 @@ export function SharedCamoufoxConfigForm({
updateFingerprintConfig("webGl2:parameters", value)
}
title="WebGL2 Parameters"
readOnly={readOnly}
/>
</div>
@@ -759,6 +826,7 @@ export function SharedCamoufoxConfigForm({
updateFingerprintConfig("webGl:shaderPrecisionFormats", value)
}
title="WebGL Shader Precision Formats"
readOnly={readOnly}
/>
</div>
@@ -775,6 +843,7 @@ export function SharedCamoufoxConfigForm({
updateFingerprintConfig("webGl2:shaderPrecisionFormats", value)
}
title="WebGL2 Shader Precision Formats"
readOnly={readOnly}
/>
</div>
@@ -876,15 +945,55 @@ export function SharedCamoufoxConfigForm({
// Advanced mode only (for editing)
renderAdvancedForm()
) : (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<Tabs
value={activeTab}
onValueChange={readOnly ? undefined : setActiveTab}
className="w-full"
>
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="automatic">Automatic</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
<TabsTrigger value="automatic" disabled={readOnly}>
Automatic
</TabsTrigger>
<TabsTrigger value="manual" disabled={readOnly}>
Manual
</TabsTrigger>
</TabsList>
<TabsContent value="automatic" className="space-y-6">
{/* Operating System Selection */}
<div className="mt-4 space-y-3">
<Label>Operating System Fingerprint</Label>
<Select
value={selectedOS}
onValueChange={(value: CamoufoxOS) =>
onConfigChange("os", value)
}
disabled={readOnly}
>
<SelectTrigger>
<SelectValue placeholder="Select operating system" />
</SelectTrigger>
<SelectContent>
<SelectItem value="windows">{osLabels.windows}</SelectItem>
<SelectItem value="macos">{osLabels.macos}</SelectItem>
<SelectItem value="linux">{osLabels.linux}</SelectItem>
</SelectContent>
</Select>
{isOSDifferent && (
<Alert className="border-yellow-500/50 bg-yellow-500/10">
<AlertDescription className="text-yellow-600 dark:text-yellow-400">
Warning: Selecting an OS different from your current
system ({osLabels[currentOS]}) increases the risk of
detection. Websites with advanced protections can detect
mismatches between your fingerprint and actual system
behavior.
</AlertDescription>
</Alert>
)}
</div>
{/* Randomize Fingerprint Option */}
<div className="mt-4 space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<Checkbox
id="randomize-fingerprint-auto"
@@ -892,6 +1001,7 @@ export function SharedCamoufoxConfigForm({
onCheckedChange={(checked) =>
onConfigChange("randomize_fingerprint_on_launch", checked)
}
disabled={readOnly}
/>
<Label
htmlFor="randomize-fingerprint-auto"
+503
View File
@@ -0,0 +1,503 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { FilteredTrafficStats } from "@/types";
type TimePeriod =
| "1m"
| "5m"
| "30m"
| "1h"
| "2h"
| "4h"
| "1d"
| "7d"
| "30d"
| "all";
interface TrafficDetailsDialogProps {
isOpen: boolean;
onClose: () => void;
profileId?: string;
profileName?: string;
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const formatBytesPerSecond = (bytes: number): string => {
if (bytes === 0) return "0 B/s";
if (bytes < 1024) return `${bytes} B/s`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB/s`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB/s`;
};
function getSecondsForPeriod(period: TimePeriod): number {
switch (period) {
case "1m":
return 60;
case "5m":
return 300;
case "30m":
return 1800;
case "1h":
return 3600;
case "2h":
return 7200;
case "4h":
return 14400;
case "1d":
return 86400;
case "7d":
return 604800;
case "30d":
return 2592000;
case "all":
return 0; // 0 means all time
default:
return 300;
}
}
export function TrafficDetailsDialog({
isOpen,
onClose,
profileId,
profileName,
}: TrafficDetailsDialogProps) {
const [stats, setStats] = React.useState<FilteredTrafficStats | null>(null);
const [timePeriod, setTimePeriod] = React.useState<TimePeriod>("5m");
// Fetch stats periodically - now uses filtered API
React.useEffect(() => {
if (!isOpen || !profileId) return;
const fetchStats = async () => {
try {
const seconds = getSecondsForPeriod(timePeriod);
const filteredStats = await invoke<FilteredTrafficStats | null>(
"get_traffic_stats_for_period",
{ profileId, seconds },
);
setStats(filteredStats);
} catch (error) {
console.error("Failed to fetch traffic stats:", error);
}
};
void fetchStats();
const interval = setInterval(fetchStats, 2000);
return () => clearInterval(interval);
}, [isOpen, profileId, timePeriod]);
// Transform data for chart (already filtered by backend)
const chartData = React.useMemo(() => {
if (!stats?.bandwidth_history) return [];
return stats.bandwidth_history.map((d) => ({
time: d.timestamp,
sent: d.bytes_sent,
received: d.bytes_received,
total: d.bytes_sent + d.bytes_received,
}));
}, [stats]);
// Tooltip render function
const renderTooltip = React.useCallback(
(props: TooltipContentProps<number, string>) => {
const { active, payload, label } = props;
if (!active || !payload?.length) return null;
const time = new Date((typeof label === "number" ? label : 0) * 1000);
const formattedTime = time.toLocaleTimeString();
return (
<div className="bg-popover border rounded-lg px-3 py-2 shadow-lg">
<p className="text-xs text-muted-foreground mb-1">{formattedTime}</p>
{payload.map((entry) => (
<p key={String(entry.dataKey)} className="text-sm">
<span className="text-muted-foreground">
{entry.dataKey === "sent" ? "↑ Sent: " : "↓ Received: "}
</span>
<span className="font-medium">
{formatBytesPerSecond(
typeof entry.value === "number" ? entry.value : 0,
)}
</span>
</p>
))}
</div>
);
},
[],
);
// Top domains sorted by total traffic
const topDomainsByTraffic = React.useMemo(() => {
if (!stats?.domains) return [];
return Object.values(stats.domains)
.sort(
(a, b) =>
b.bytes_sent + b.bytes_received - (a.bytes_sent + a.bytes_received),
)
.slice(0, 10);
}, [stats]);
// Top domains sorted by request count
const topDomainsByRequests = React.useMemo(() => {
if (!stats?.domains) return [];
return Object.values(stats.domains)
.sort((a, b) => b.request_count - a.request_count)
.slice(0, 10);
}, [stats]);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
Traffic Details
{profileName && (
<span className="text-muted-foreground font-normal ml-2">
{profileName}
</span>
)}
</DialogTitle>
</DialogHeader>
<ScrollArea className="h-[60vh]">
<div className="space-y-6 pr-4">
{/* Chart with Period Selector */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium">Bandwidth Over Time</h3>
<Select
value={timePeriod}
onValueChange={(v) => setTimePeriod(v as TimePeriod)}
>
<SelectTrigger className="w-[120px] h-8">
<SelectValue placeholder="Time period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1m">Last 1 min</SelectItem>
<SelectItem value="5m">Last 5 min</SelectItem>
<SelectItem value="30m">Last 30 min</SelectItem>
<SelectItem value="1h">Last 1 hour</SelectItem>
<SelectItem value="2h">Last 2 hours</SelectItem>
<SelectItem value="4h">Last 4 hours</SelectItem>
<SelectItem value="1d">Last 1 day</SelectItem>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
<SelectItem value="all">All time</SelectItem>
</SelectContent>
</Select>
</div>
<div className="h-[200px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="sentGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-1)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient
id="receivedGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-2)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-2)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="time"
tickFormatter={(t) =>
new Date(t * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
/>
<YAxis
tickFormatter={(v) => formatBytesPerSecond(v)}
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
width={60}
/>
<Tooltip content={renderTooltip} />
<Area
type="monotone"
dataKey="sent"
stackId="1"
stroke="var(--chart-1)"
fill="url(#sentGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="received"
stackId="1"
stroke="var(--chart-2)"
fill="url(#receivedGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-6 mt-2">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">Sent</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">
Received
</span>
</div>
</div>
</div>
{/* Period Stats - now uses backend-computed values */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">
Sent ({timePeriod === "all" ? "total" : timePeriod})
</p>
<p className="text-lg font-semibold text-chart-1">
{formatBytes(stats?.period_bytes_sent || 0)}
</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">
Received ({timePeriod === "all" ? "total" : timePeriod})
</p>
<p className="text-lg font-semibold text-chart-2">
{formatBytes(stats?.period_bytes_received || 0)}
</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Requests</p>
<p className="text-lg font-semibold">
{(stats?.total_requests || 0).toLocaleString()}
</p>
</div>
</div>
{/* Total Stats (smaller, under period stats) */}
<div className="flex items-center gap-6 text-sm text-muted-foreground border-t pt-4">
<div>
<span className="font-medium">Total:</span>{" "}
{formatBytes(
(stats?.total_bytes_sent || 0) +
(stats?.total_bytes_received || 0),
)}
</div>
<div>
<span className="font-medium">Requests:</span>{" "}
{stats?.total_requests?.toLocaleString() || 0}
</div>
</div>
{/* Disclaimer about proxy/VPN traffic calculation */}
<p className="text-xs text-muted-foreground italic">
Note: If you are using a proxy, VPN, or similar service, your
provider may calculate traffic differently due to encryption
overhead and protocol differences.
</p>
{/* Top Domains by Traffic */}
{topDomainsByTraffic.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2">
Top Domains by Traffic
</h3>
<div className="border rounded-md">
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
<span>Domain</span>
<span className="text-right">Requests</span>
<span className="text-right">Sent</span>
<span className="text-right">Received</span>
</div>
<div className="max-h-[180px] overflow-y-auto">
{topDomainsByTraffic.map((domain, index) => (
<div
key={domain.domain}
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-muted/30"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-muted-foreground w-4 shrink-0">
{index + 1}
</span>
<span className="truncate" title={domain.domain}>
{domain.domain}
</span>
</div>
<span className="text-right text-muted-foreground">
{domain.request_count.toLocaleString()}
</span>
<span className="text-right text-chart-1">
{formatBytes(domain.bytes_sent)}
</span>
<span className="text-right text-chart-2">
{formatBytes(domain.bytes_received)}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Top Domains by Requests */}
{topDomainsByRequests.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2">
Top Domains by Requests
</h3>
<div className="border rounded-md">
<div className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
<span>Domain</span>
<span className="text-right">Requests</span>
<span className="text-right">Total Traffic</span>
</div>
<div className="max-h-[180px] overflow-y-auto">
{topDomainsByRequests.map((domain, index) => (
<div
key={domain.domain}
className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-muted/30"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-muted-foreground w-4 shrink-0">
{index + 1}
</span>
<span className="truncate" title={domain.domain}>
{domain.domain}
</span>
</div>
<span className="text-right text-muted-foreground">
{domain.request_count.toLocaleString()}
</span>
<span className="text-right">
{formatBytes(
domain.bytes_sent + domain.bytes_received,
)}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Unique IPs */}
{stats?.unique_ips && stats.unique_ips.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2">
Unique IPs ({stats.unique_ips.length})
</h3>
<div className="border rounded-md p-3 max-h-[120px] overflow-y-auto">
<div className="flex flex-wrap gap-1.5">
{stats.unique_ips.map((ip) => (
<span
key={ip}
className="text-xs bg-muted px-2 py-1 rounded font-mono"
>
{ip}
</span>
))}
</div>
</div>
</div>
)}
{/* No data state */}
{!stats && (
<div className="text-center py-8 text-muted-foreground">
<p>No traffic data available for this profile.</p>
<p className="text-sm mt-1">
Traffic data will appear after you launch the profile.
</p>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
+1 -3
View File
@@ -1,5 +1,3 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
@@ -20,7 +18,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
+378
View File
@@ -0,0 +1,378 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type {
Props as DefaultLegendContentProps,
LegendPayload,
} from "recharts/types/component/DefaultLegendContent";
import type { Payload } from "recharts/types/component/DefaultTooltipContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
// biome-ignore lint/security/noDangerouslySetInnerHtml: Safe usage for CSS variables from chart config
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
TooltipContentProps<number, string> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
labelClassName?: string;
color?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item: Payload<number, string>) => item.type !== "none")
.map((item: Payload<number, string>, index: number) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload?.fill || item.color;
return (
<div
key={String(item.dataKey ?? index)}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item: LegendPayload) => item.type !== "none")
.map((item: LegendPayload) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useCallback, useState } from "react";
import { LuCheck, LuCopy } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { showSuccessToast } from "@/lib/toast-utils";
interface CopyToClipboardProps {
text: string;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size?: "default" | "sm" | "lg" | "icon";
className?: string;
successMessage?: string;
}
export function CopyToClipboard({
text,
variant = "outline",
size = "icon",
className,
successMessage = "Copied to clipboard",
}: CopyToClipboardProps) {
const [copied, setCopied] = useState(false);
const copyToClipboard = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
showSuccessToast(successMessage);
setTimeout(() => {
setCopied(false);
}, 2000);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
}, [text, successMessage]);
return (
<Button
variant={variant}
size={size}
className={`relative ${className || ""}`}
onClick={copyToClipboard}
aria-label={copied ? "Copied" : "Copy to clipboard"}
>
<span className="sr-only">{copied ? "Copied" : "Copy"}</span>
<LuCopy
className={`h-4 w-4 transition-all duration-300 ${
copied ? "scale-0" : "scale-100"
}`}
/>
<LuCheck
className={`absolute inset-0 m-auto h-4 w-4 transition-all duration-300 ${
copied ? "scale-100" : "scale-0"
}`}
/>
</Button>
);
}
+14 -2
View File
@@ -37,14 +37,19 @@ function TooltipTrigger({
function TooltipContent({
className,
sideOffset = 0,
alignOffset,
arrowOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
arrowOffset?: number;
}) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
@@ -52,7 +57,14 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-[50000] size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
<TooltipPrimitive.Arrow
className="fill-primary z-[50000]"
style={
arrowOffset !== 0
? { transform: `translateX(${-arrowOffset}px)` }
: undefined
}
/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
+152
View File
@@ -16,6 +16,11 @@ export interface ThemeColors extends Record<string, string> {
"--destructive": string;
"--destructive-foreground": string;
"--border": string;
"--chart-1": string;
"--chart-2": string;
"--chart-3": string;
"--chart-4": string;
"--chart-5": string;
}
export interface Theme {
@@ -46,6 +51,11 @@ export const THEMES: Theme[] = [
"--destructive": "#f7768e",
"--destructive-foreground": "#1a1b26",
"--border": "#3b4261",
"--chart-1": "#7aa2f7",
"--chart-2": "#9ece6a",
"--chart-3": "#bb9af7",
"--chart-4": "#2ac3de",
"--chart-5": "#ff9e64",
},
},
{
@@ -69,6 +79,11 @@ export const THEMES: Theme[] = [
"--destructive": "#ff5555",
"--destructive-foreground": "#f8f8f2",
"--border": "#6272a4",
"--chart-1": "#bd93f9",
"--chart-2": "#50fa7b",
"--chart-3": "#ff79c6",
"--chart-4": "#8be9fd",
"--chart-5": "#ffb86c",
},
},
{
@@ -92,6 +107,11 @@ export const THEMES: Theme[] = [
"--destructive": "#ff819f",
"--destructive-foreground": "#273136",
"--border": "#304e37",
"--chart-1": "#7eb08a",
"--chart-2": "#d2b48c",
"--chart-3": "#7ea4b0",
"--chart-4": "#a8c97f",
"--chart-5": "#e6c07b",
},
},
{
@@ -115,6 +135,11 @@ export const THEMES: Theme[] = [
"--destructive": "#ef4444",
"--destructive-foreground": "#f7f7f8",
"--border": "#2a2e39",
"--chart-1": "#5755d9",
"--chart-2": "#0ea5e9",
"--chart-3": "#f25f4c",
"--chart-4": "#22c55e",
"--chart-5": "#f59e0b",
},
},
{
@@ -138,6 +163,11 @@ export const THEMES: Theme[] = [
"--destructive": "#f07178",
"--destructive-foreground": "#b3b1ad",
"--border": "#1f2430",
"--chart-1": "#39bae6",
"--chart-2": "#c2d94c",
"--chart-3": "#d2a6ff",
"--chart-4": "#ffb454",
"--chart-5": "#f07178",
},
},
{
@@ -161,6 +191,123 @@ export const THEMES: Theme[] = [
"--destructive": "#f07178",
"--destructive-foreground": "#fafafa",
"--border": "#e7eaed",
"--chart-1": "#399ee6",
"--chart-2": "#86b300",
"--chart-3": "#a37acc",
"--chart-4": "#fa8d3e",
"--chart-5": "#f07178",
},
},
{
id: "catppuccin-latte",
name: "Catppuccin Latte",
colors: {
"--background": "#eff1f5",
"--foreground": "#4c4f69",
"--card": "#ccd0da",
"--card-foreground": "#4c4f69",
"--popover": "#ccd0da",
"--popover-foreground": "#4c4f69",
"--primary": "#1e66f5",
"--primary-foreground": "#eff1f5",
"--secondary": "#04a5e5",
"--secondary-foreground": "#eff1f5",
"--muted": "#bcc0cc",
"--muted-foreground": "#5c5f77",
"--accent": "#8839ef",
"--accent-foreground": "#eff1f5",
"--destructive": "#d20f39",
"--destructive-foreground": "#eff1f5",
"--border": "#9ca0b0",
"--chart-1": "#1e66f5",
"--chart-2": "#40a02b",
"--chart-3": "#8839ef",
"--chart-4": "#04a5e5",
"--chart-5": "#df8e1d",
},
},
{
id: "catppuccin-frappe",
name: "Catppuccin Frappe",
colors: {
"--background": "#303446",
"--foreground": "#c6d0f5",
"--card": "#414559",
"--card-foreground": "#c6d0f5",
"--popover": "#414559",
"--popover-foreground": "#c6d0f5",
"--primary": "#8caaee",
"--primary-foreground": "#303446",
"--secondary": "#99d1db",
"--secondary-foreground": "#303446",
"--muted": "#51576d",
"--muted-foreground": "#b5bfe2",
"--accent": "#ca9ee6",
"--accent-foreground": "#303446",
"--destructive": "#e78284",
"--destructive-foreground": "#303446",
"--border": "#737994",
"--chart-1": "#8caaee",
"--chart-2": "#a6d189",
"--chart-3": "#ca9ee6",
"--chart-4": "#99d1db",
"--chart-5": "#e5c890",
},
},
{
id: "catppuccin-macchiato",
name: "Catppuccin Macchiato",
colors: {
"--background": "#24273a",
"--foreground": "#cad3f5",
"--card": "#363a4f",
"--card-foreground": "#cad3f5",
"--popover": "#363a4f",
"--popover-foreground": "#cad3f5",
"--primary": "#8aadf4",
"--primary-foreground": "#24273a",
"--secondary": "#91d7e3",
"--secondary-foreground": "#24273a",
"--muted": "#494d64",
"--muted-foreground": "#b8c0e0",
"--accent": "#c6a0f6",
"--accent-foreground": "#24273a",
"--destructive": "#ed8796",
"--destructive-foreground": "#24273a",
"--border": "#6e738d",
"--chart-1": "#8aadf4",
"--chart-2": "#a6da95",
"--chart-3": "#c6a0f6",
"--chart-4": "#91d7e3",
"--chart-5": "#eed49f",
},
},
{
id: "catppuccin-mocha",
name: "Catppuccin Mocha",
colors: {
"--background": "#1e1e2e",
"--foreground": "#cdd6f4",
"--card": "#313244",
"--card-foreground": "#cdd6f4",
"--popover": "#313244",
"--popover-foreground": "#cdd6f4",
"--primary": "#89b4fa",
"--primary-foreground": "#1e1e2e",
"--secondary": "#89dceb",
"--secondary-foreground": "#1e1e2e",
"--muted": "#45475a",
"--muted-foreground": "#bac2de",
"--accent": "#cba6f7",
"--accent-foreground": "#1e1e2e",
"--destructive": "#f38ba8",
"--destructive-foreground": "#1e1e2e",
"--border": "#585b70",
"--chart-1": "#89b4fa",
"--chart-2": "#a6e3a1",
"--chart-3": "#cba6f7",
"--chart-4": "#89dceb",
"--chart-5": "#f9e2af",
},
},
];
@@ -184,6 +331,11 @@ export const THEME_VARIABLES: Array<{ key: keyof ThemeColors; label: string }> =
{ key: "--destructive", label: "Destructive" },
{ key: "--destructive-foreground", label: "Destructive FG" },
{ key: "--border", label: "Border" },
{ key: "--chart-1", label: "Chart 1" },
{ key: "--chart-2", label: "Chart 2" },
{ key: "--chart-3", label: "Chart 3" },
{ key: "--chart-4", label: "Chart 4" },
{ key: "--chart-5", label: "Chart 5" },
];
export function getThemeById(id: string): Theme | undefined {
+10
View File
@@ -79,6 +79,11 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
@@ -113,6 +118,11 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
@layer base {
+60 -1
View File
@@ -7,7 +7,7 @@ export interface ProxySettings {
}
export interface TableSortingSettings {
column: string; // "name", "browser", "status"
column: string; // "name", "note", "status"
direction: string; // "asc" or "desc"
}
@@ -23,6 +23,7 @@ export interface BrowserProfile {
camoufox_config?: CamoufoxConfig; // Camoufox configuration
group_id?: string; // Reference to profile group
tags?: string[];
note?: string; // User note
}
export interface ProxyCheckResult {
@@ -82,6 +83,8 @@ export interface AppUpdateProgress {
message: string;
}
export type CamoufoxOS = "windows" | "macos" | "linux";
export interface CamoufoxConfig {
proxy?: string;
screen_max_width?: number;
@@ -95,6 +98,7 @@ export interface CamoufoxConfig {
executable_path?: string;
fingerprint?: string; // JSON string of the complete fingerprint config
randomize_fingerprint_on_launch?: boolean; // Generate new fingerprint on every launch
os?: CamoufoxOS; // Operating system for fingerprint generation
}
// Extended interface for the advanced fingerprint configuration
@@ -264,3 +268,58 @@ export interface CamoufoxLaunchResult {
profilePath?: string;
url?: string;
}
// Traffic stats types
export interface BandwidthDataPoint {
timestamp: number;
bytes_sent: number;
bytes_received: number;
}
export interface DomainAccess {
domain: string;
request_count: number;
bytes_sent: number;
bytes_received: number;
first_access: number;
last_access: number;
}
export interface TrafficStats {
proxy_id: string;
profile_id?: string;
session_start: number;
last_update: number;
total_bytes_sent: number;
total_bytes_received: number;
total_requests: number;
bandwidth_history: BandwidthDataPoint[];
domains: Record<string, DomainAccess>;
unique_ips: string[];
}
export interface TrafficSnapshot {
profile_id?: string;
session_start: number;
last_update: number;
total_bytes_sent: number;
total_bytes_received: number;
total_requests: number;
current_bytes_sent: number;
current_bytes_received: number;
recent_bandwidth: BandwidthDataPoint[];
}
export interface FilteredTrafficStats {
profile_id?: string;
session_start: number;
last_update: number;
total_bytes_sent: number;
total_bytes_received: number;
total_requests: number;
bandwidth_history: BandwidthDataPoint[];
period_bytes_sent: number;
period_bytes_received: number;
domains: Record<string, DomainAccess>;
unique_ips: string[];
}
+4 -2
View File
@@ -13,7 +13,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -29,7 +29,9 @@
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
"dist/types/**/*.ts"
"dist/types/**/*.ts",
".next/dev/types/**/*.ts",
"dist/dev/types/**/*.ts"
],
"exclude": ["node_modules", "nodecar", "src-tauri/target"]
}