Compare commits

...

16 Commits

Author SHA1 Message Date
zhom 689ac8e3ca fix: windows build correct string literal 2025-07-07 07:34:55 +04:00
zhom 0e1c5dcfb6 docs: add feature description 2025-07-07 07:33:41 +04:00
zhom f22a9f3557 style: copy 2025-07-07 07:13:26 +04:00
zhom 5a76fe3221 tests: treat all camoufox versions as stable 2025-07-07 07:13:03 +04:00
zhom 5edad9b97c fix: prevent version downgrade for camoufox 2025-07-07 07:04:49 +04:00
zhom 38556fc504 style: copy and minor self-update modal logic change 2025-07-07 06:44:15 +04:00
zhom 703ca2c50b feat: add anti-detect functionality 2025-07-07 06:19:43 +04:00
zhom 198046fca9 Merge pull request #42 from zhom/dependabot/cargo/src-tauri/rust-dependencies-77d4c5ce85
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 10 updates
2025-07-05 11:33:20 +00:00
zhom fdcce5c86a Merge pull request #41 from zhom/dependabot/npm_and_yarn/frontend-dependencies-199434007a
deps(deps): bump the frontend-dependencies group with 35 updates
2025-07-05 11:33:00 +00:00
zhom 1cd1c7b59d Merge pull request #40 from zhom/dependabot/github_actions/github-actions-4aaa0eafdc
ci(deps): bump crate-ci/typos from 1.33.1 to 1.34.0 in the github-actions group
2025-07-05 11:32:43 +00:00
dependabot[bot] d803361fca deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [reqwest](https://github.com/seanmonstar/reqwest) | `0.12.20` | `0.12.22` |
| [tokio](https://github.com/tokio-rs/tokio) | `1.45.1` | `1.46.1` |
| [async-channel](https://github.com/smol-rs/async-channel) | `2.3.1` | `2.4.0` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.27` | `1.2.28` |
| [h2](https://github.com/hyperium/h2) | `0.4.10` | `0.4.11` |
| [rust-ini](https://github.com/zonyitoo/rust-ini) | `0.21.1` | `0.21.2` |
| [serde_with](https://github.com/jonasbb/serde_with) | `3.13.0` | `3.14.0` |
| [serde_with_macros](https://github.com/jonasbb/serde_with) | `3.13.0` | `3.14.0` |
| [shared_child](https://github.com/oconnor663/shared_child.rs) | `1.1.0` | `1.1.1` |
| [sigchld](https://github.com/oconnor663/sigchld.rs) | `0.2.3` | `0.2.4` |


Updates `reqwest` from 0.12.20 to 0.12.22
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.12.20...v0.12.22)

Updates `tokio` from 1.45.1 to 1.46.1
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.45.1...tokio-1.46.1)

Updates `async-channel` from 2.3.1 to 2.4.0
- [Release notes](https://github.com/smol-rs/async-channel/releases)
- [Changelog](https://github.com/smol-rs/async-channel/blob/master/CHANGELOG.md)
- [Commits](https://github.com/smol-rs/async-channel/compare/v2.3.1...v2.4.0)

Updates `cc` from 1.2.27 to 1.2.28
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.27...cc-v1.2.28)

Updates `h2` from 0.4.10 to 0.4.11
- [Release notes](https://github.com/hyperium/h2/releases)
- [Changelog](https://github.com/hyperium/h2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/h2/compare/v0.4.10...v0.4.11)

Updates `rust-ini` from 0.21.1 to 0.21.2
- [Release notes](https://github.com/zonyitoo/rust-ini/releases)
- [Commits](https://github.com/zonyitoo/rust-ini/compare/v0.21.1...v0.21.2)

Updates `serde_with` from 3.13.0 to 3.14.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.13.0...v3.14.0)

Updates `serde_with_macros` from 3.13.0 to 3.14.0
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.13.0...v3.14.0)

Updates `shared_child` from 1.1.0 to 1.1.1
- [Commits](https://github.com/oconnor663/shared_child.rs/compare/1.1.0...1.1.1)

Updates `sigchld` from 0.2.3 to 0.2.4
- [Commits](https://github.com/oconnor663/sigchld.rs/compare/0.2.3...0.2.4)

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.12.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tokio
  dependency-version: 1.46.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: async-channel
  dependency-version: 2.4.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.28
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: h2
  dependency-version: 0.4.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rust-ini
  dependency-version: 0.21.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: serde_with
  dependency-version: 3.14.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serde_with_macros
  dependency-version: 3.14.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: shared_child
  dependency-version: 1.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: sigchld
  dependency-version: 0.2.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-05 09:32:16 +00:00
dependabot[bot] 2f6f20eb29 deps(deps): bump the frontend-dependencies group with 35 updates
---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: sonner
  dependency-version: 2.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@types/node"
  dependency-version: 24.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: tw-animate-css
  dependency-version: 1.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: dotenv
  dependency-version: 17.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/env"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-arm64"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-x64"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-gnu"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-musl"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-gnu"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-musl"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-arm64-msvc"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-x64-msvc"
  dependency-version: 15.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loongarch64-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-powerpc64le-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: rollup
  dependency-version: 4.44.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-05 09:18:32 +00:00
dependabot[bot] 59272e0cff ci(deps): bump crate-ci/typos in the github-actions group
Bumps the github-actions group with 1 update: [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `crate-ci/typos` from 1.33.1 to 1.34.0
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/b1ae8d918b6e85bd611117d3d9a3be4f903ee5e4...392b78fe18a52790c53f42456e46124f77346842)

---
updated-dependencies:
- dependency-name: crate-ci/typos
  dependency-version: 1.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-05 09:12:21 +00:00
zhom cac2273ad3 chore: version bump 2025-07-05 02:40:15 +04:00
zhom 1691a7a06b refactor: make mullvad handle links the same way tor browser does 2025-07-05 02:38:17 +04:00
zhom 5a4718fba6 refactor: more robust profile import logic 2025-07-05 01:58:27 +04:00
44 changed files with 6362 additions and 1065 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
with:
prompt-file: issue_analysis.txt
system-prompt: |
You are an issue validation assistant for Donut Browser, a browser orchestrator application.
You are an issue validation assistant for Donut Browser, an anti-detect browser.
Analyze the provided issue content and determine if it contains sufficient information based on these requirements:
@@ -62,7 +62,7 @@ jobs:
with:
prompt-file: commits.txt
system-prompt: |
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful browser orchestrator application.
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful anti-detect browser.
Analyze the provided commit messages and generate well-structured release notes following this format:
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Spell Check Repo
uses: crate-ci/typos@b1ae8d918b6e85bd611117d3d9a3be4f903ee5e4 #v1.33.1
uses: crate-ci/typos@392b78fe18a52790c53f42456e46124f77346842 #v1.34.0
+17 -1
View File
@@ -8,6 +8,7 @@
"autoconfig",
"autologin",
"biomejs",
"camoufox",
"cdylib",
"CFURL",
"checkin",
@@ -15,6 +16,7 @@
"clippy",
"cmdk",
"codegen",
"CTYPE",
"devedition",
"doesn",
"donutbrowser",
@@ -26,8 +28,11 @@
"esac",
"esbuild",
"frontmost",
"geoip",
"gettimezone",
"gifs",
"gsettings",
"hkcu",
"icns",
"idletime",
"Inno",
@@ -42,6 +47,8 @@
"librsvg",
"libwebkit",
"libxdo",
"localtime",
"mmdb",
"mountpoint",
"msiexec",
"msvc",
@@ -58,6 +65,7 @@
"osascript",
"pixbuf",
"plasmohq",
"prefs",
"propertylist",
"reqwest",
"ridedott",
@@ -68,17 +76,22 @@
"shadcn",
"signon",
"sonner",
"splitn",
"sspi",
"staticlib",
"stefanzweifel",
"subdirs",
"subkey",
"SUPPRESSMSGBOXES",
"swatinem",
"sysinfo",
"systempreferences",
"systemsetup",
"taskkill",
"tasklist",
"tauri",
"TERX",
"timedatectl",
"titlebar",
"Torbrowser",
"turbopack",
@@ -89,9 +102,12 @@
"urlencoding",
"vercel",
"VERYSILENT",
"webgl",
"webrtc",
"winreg",
"wiremock",
"xattr",
"zhom"
"zhom",
"zoneinfo"
]
}
+3 -2
View File
@@ -1,7 +1,7 @@
<div align="center">
<img src="assets/logo.png" alt="Donut Browser Logo" width="150">
<h1>Donut Browser</h1>
<strong>A powerful browser orchestrator that puts you in control of your browsing experience. 🍩</strong>
<strong>A powerful anti-detect browser that puts you in control of your browsing experience. 🍩</strong>
</div>
<br>
@@ -27,7 +27,7 @@
## Donut Browser
> A free and open source browser orchestrator built with [Tauri](https://v2.tauri.app/).
> A free and open source anti-detect browser built with [Tauri](https://v2.tauri.app/).
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/preview-dark.png" />
@@ -38,6 +38,7 @@
## Features
- Create unlimited number of local browser profiles completely isolated from each other
- Bypass website restrictions and avoid getting banned by using anti-detection features powered by [Camoufox](https://camoufox.com/)
- Proxy support with basic auth for all browsers except for TOR Browser
- Import profiles from your existing browsers
- Automatic updates both for browsers and for the app itself
+3 -2
View File
@@ -21,10 +21,11 @@
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^24.0.7",
"@types/node": "^24.0.10",
"@yao-pkg/pkg": "^6.5.1",
"camoufox-js": "^0.6.0",
"commander": "^14.0.0",
"dotenv": "^17.0.0",
"dotenv": "^17.0.1",
"get-port": "^7.1.0",
"nodemon": "^3.1.10",
"proxy-chain": "^2.5.9",
+503
View File
@@ -0,0 +1,503 @@
import { spawn } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
export interface CamoufoxConfig {
id: string;
pid?: number;
executablePath: string;
profilePath: string;
url?: string;
options: CamoufoxLaunchOptions;
}
export interface CamoufoxLaunchOptions {
// Operating system to use for fingerprint generation
os?: "windows" | "macos" | "linux" | string[];
// Blocking options
block_images?: boolean;
block_webrtc?: boolean;
block_webgl?: boolean;
// Security options
disable_coop?: boolean;
// Geolocation options
geoip?: string | boolean;
// UI behavior
humanize?: boolean | number;
// Localization
locale?: string | string[];
// Extensions and fonts
addons?: string[];
fonts?: string[];
custom_fonts_only?: boolean;
exclude_addons?: string[];
// Screen and window
screen?: {
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
};
window?: [number, number];
// Fingerprint
fingerprint?: any;
// Version and mode
ff_version?: number;
headless?: boolean;
main_world_eval?: boolean;
// Custom executable path
executable_path?: string;
// Firefox preferences
firefox_user_prefs?: Record<string, any>;
// Proxy settings
proxy?:
| string
| {
server: string;
username?: string;
password?: string;
bypass?: string;
};
// Cache and performance
enable_cache?: boolean;
// Additional options
args?: string[];
env?: Record<string, string | number | boolean>;
debug?: boolean;
virtual_display?: string;
webgl_config?: [string, string];
// Custom options
timezone?: string;
country?: string;
geolocation?: {
latitude: number;
longitude: number;
accuracy?: number;
};
}
// Store for active Camoufox processes
const activeCamoufoxProcesses = new Map<string, CamoufoxConfig>();
/**
* Generate a unique ID for the Camoufox instance
*/
function generateCamoufoxId(): string {
return `camoufox_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Save Camoufox configuration to storage
*/
function saveCamoufoxConfig(config: CamoufoxConfig): void {
try {
const configDir = path.join(os.tmpdir(), "nodecar_camoufox");
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
const configFile = path.join(configDir, `${config.id}.json`);
fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
activeCamoufoxProcesses.set(config.id, config);
} catch (error) {
console.error(`Failed to save Camoufox config: ${error}`);
}
}
/**
* Load Camoufox configuration from storage
*/
function loadCamoufoxConfig(id: string): CamoufoxConfig | null {
try {
const configFile = path.join(os.tmpdir(), "nodecar_camoufox", `${id}.json`);
if (fs.existsSync(configFile)) {
const config = JSON.parse(fs.readFileSync(configFile, "utf8"));
activeCamoufoxProcesses.set(id, config);
return config;
}
} catch (error) {
console.error(`Failed to load Camoufox config: ${error}`);
}
return null;
}
/**
* Delete Camoufox configuration from storage
*/
function deleteCamoufoxConfig(id: string): boolean {
try {
const configFile = path.join(os.tmpdir(), "nodecar_camoufox", `${id}.json`);
if (fs.existsSync(configFile)) {
fs.unlinkSync(configFile);
}
activeCamoufoxProcesses.delete(id);
return true;
} catch (error) {
console.error(`Failed to delete Camoufox config: ${error}`);
return false;
}
}
/**
* Load all Camoufox configurations on startup
*/
function loadAllCamoufoxConfigs(): void {
try {
const configDir = path.join(os.tmpdir(), "nodecar_camoufox");
if (fs.existsSync(configDir)) {
const files = fs.readdirSync(configDir);
for (const file of files) {
if (file.endsWith(".json")) {
const id = path.basename(file, ".json");
loadCamoufoxConfig(id);
}
}
}
} catch (error) {
console.error(`Failed to load Camoufox configs: ${error}`);
}
}
/**
* Check if a process is still running
*/
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return false;
}
}
/**
* Convert Camoufox options to command line arguments
*/
function buildCamoufoxArgs(
options: CamoufoxLaunchOptions,
profilePath: string,
url?: string,
): string[] {
const args: string[] = [];
// Always use profile
args.push("-profile", profilePath);
// Cache enabled by default as requested
if (options.enable_cache !== false) {
// Cache is enabled by default in Camoufox, no special args needed
}
// Headless mode
if (options.headless) {
args.push("-headless");
}
// No remote for security (anti-detect)
args.push("-no-remote");
// Custom Firefox user preferences will be written to user.js in profile
// Additional custom args
if (options.args) {
args.push(...options.args);
}
// URL to open
if (url) {
args.push(url);
}
return args;
}
/**
* Create user.js file with Camoufox preferences
*/
function createUserJs(
profilePath: string,
options: CamoufoxLaunchOptions,
): void {
const preferences: string[] = [];
// Anti-detect preferences
preferences.push('user_pref("privacy.resistFingerprinting", true);');
preferences.push(
'user_pref("privacy.resistFingerprinting.letterboxing", true);',
);
preferences.push('user_pref("privacy.trackingprotection.enabled", true);');
// Disable telemetry and data collection
preferences.push(
'user_pref("datareporting.healthreport.uploadEnabled", false);',
);
preferences.push(
'user_pref("datareporting.policy.dataSubmissionEnabled", false);',
);
preferences.push('user_pref("toolkit.telemetry.enabled", false);');
preferences.push('user_pref("toolkit.telemetry.unified", false);');
// Block options
if (options.block_images) {
preferences.push('user_pref("permissions.default.image", 2);');
}
if (options.block_webrtc) {
preferences.push('user_pref("media.peerconnection.enabled", false);');
preferences.push('user_pref("media.navigator.enabled", false);');
}
if (options.block_webgl) {
preferences.push('user_pref("webgl.disabled", true);');
preferences.push('user_pref("webgl.disable-extensions", true);');
}
// COOP settings
if (options.disable_coop) {
preferences.push(
'user_pref("browser.tabs.remote.useCrossOriginOpenerPolicy", false);',
);
}
// Locale settings
if (options.locale) {
const localeStr = Array.isArray(options.locale)
? options.locale[0]
: options.locale;
preferences.push(`user_pref("intl.locale.requested", "${localeStr}");`);
preferences.push(`user_pref("general.useragent.locale", "${localeStr}");`);
}
// Timezone
if (options.timezone) {
preferences.push(
`user_pref("privacy.resistFingerprinting.timezone", "${options.timezone}");`,
);
}
// Custom Firefox preferences
if (options.firefox_user_prefs) {
for (const [key, value] of Object.entries(options.firefox_user_prefs)) {
if (typeof value === "string") {
preferences.push(`user_pref("${key}", "${value}");`);
} else if (typeof value === "boolean") {
preferences.push(`user_pref("${key}", ${value});`);
} else if (typeof value === "number") {
preferences.push(`user_pref("${key}", ${value});`);
}
}
}
// Proxy settings
if (options.proxy) {
if (typeof options.proxy === "string") {
// Parse proxy URL
try {
const proxyUrl = new URL(options.proxy);
const port =
parseInt(proxyUrl.port) ||
(proxyUrl.protocol === "https:" ? 443 : 80);
if (proxyUrl.protocol.startsWith("socks")) {
preferences.push('user_pref("network.proxy.type", 1);');
preferences.push(
`user_pref("network.proxy.socks", "${proxyUrl.hostname}");`,
);
preferences.push(`user_pref("network.proxy.socks_port", ${port});`);
if (proxyUrl.protocol === "socks5:") {
preferences.push('user_pref("network.proxy.socks_version", 5);');
} else {
preferences.push('user_pref("network.proxy.socks_version", 4);');
}
} else {
preferences.push('user_pref("network.proxy.type", 1);');
preferences.push(
`user_pref("network.proxy.http", "${proxyUrl.hostname}");`,
);
preferences.push(`user_pref("network.proxy.http_port", ${port});`);
preferences.push(
`user_pref("network.proxy.ssl", "${proxyUrl.hostname}");`,
);
preferences.push(`user_pref("network.proxy.ssl_port", ${port});`);
}
if (proxyUrl.username && proxyUrl.password) {
// Note: Basic auth for proxies is handled differently in modern Firefox
preferences.push(
'user_pref("network.proxy.allow_hijacking_localhost", true);',
);
}
} catch (error) {
console.error(`Invalid proxy URL: ${options.proxy}`);
}
}
}
// Geolocation
if (options.geolocation) {
preferences.push('user_pref("geo.enabled", true);');
preferences.push(
`user_pref("geo.wifi.uri", "data:application/json,{\\"location\\": {\\"lat\\": ${options.geolocation.latitude}, \\"lng\\": ${options.geolocation.longitude}}, \\"accuracy\\": ${options.geolocation.accuracy || 100}}");`,
);
} else {
preferences.push('user_pref("geo.enabled", false);');
}
// Write user.js file
const userJsPath = path.join(profilePath, "user.js");
fs.writeFileSync(userJsPath, preferences.join("\n"));
}
/**
* Launch Camoufox browser with specified options
*/
export async function launchCamoufox(
executablePath: string,
profilePath: string,
options: CamoufoxLaunchOptions = {},
url?: string,
): Promise<CamoufoxConfig> {
const id = generateCamoufoxId();
// Ensure profile directory exists
if (!fs.existsSync(profilePath)) {
fs.mkdirSync(profilePath, { recursive: true });
}
// Create user.js with preferences
createUserJs(profilePath, options);
// Build command line arguments
const args = buildCamoufoxArgs(options, profilePath, url);
// Prepare environment variables
const env = {
...process.env,
...options.env,
};
// Handle virtual display
if (options.virtual_display) {
env.DISPLAY = options.virtual_display;
}
// Launch the process
const child = spawn(executablePath, args, {
env: env as NodeJS.ProcessEnv,
detached: true,
stdio: options.debug ? "inherit" : "ignore",
});
if (!child.pid) {
throw new Error("Failed to launch Camoufox process");
}
const config: CamoufoxConfig = {
id,
pid: child.pid,
executablePath,
profilePath,
url,
options,
};
// Save configuration
saveCamoufoxConfig(config);
// Handle process exit
child.on("exit", (code, signal) => {
console.log(
`Camoufox process ${child.pid} exited with code ${code}, signal ${signal}`,
);
deleteCamoufoxConfig(id);
});
child.on("error", (error) => {
console.error(`Camoufox process error: ${error}`);
deleteCamoufoxConfig(id);
});
// Detach the child process so it can continue running independently
child.unref();
return config;
}
/**
* Stop a Camoufox process by ID
*/
export async function stopCamoufox(id: string): Promise<boolean> {
const config = activeCamoufoxProcesses.get(id) || loadCamoufoxConfig(id);
if (!config || !config.pid) {
return false;
}
try {
if (isProcessRunning(config.pid)) {
process.kill(config.pid, "SIGTERM");
// Wait a moment for graceful shutdown
await new Promise((resolve) => setTimeout(resolve, 2000));
// Force kill if still running
if (isProcessRunning(config.pid)) {
process.kill(config.pid, "SIGKILL");
}
}
deleteCamoufoxConfig(id);
return true;
} catch (error) {
console.error(`Failed to stop Camoufox process: ${error}`);
return false;
}
}
/**
* List all Camoufox processes
*/
export function listCamoufoxProcesses(): any[] {
loadAllCamoufoxConfigs();
// Filter out dead processes
const activeConfigs: any[] = [];
for (const [id, config] of activeCamoufoxProcesses) {
if (config.pid && isProcessRunning(config.pid)) {
// Return in snake_case format for Rust compatibility
activeConfigs.push({
id: config.id,
pid: config.pid,
executable_path: config.executablePath,
profile_path: config.profilePath,
url: config.url,
options: config.options,
});
} else {
// Clean up dead processes
deleteCamoufoxConfig(id);
}
}
return activeConfigs;
}
// Load existing configurations on module initialization
loadAllCamoufoxConfigs();
+278
View File
@@ -1,4 +1,9 @@
import { program } from "commander";
import {
launchCamoufox,
listCamoufoxProcesses,
stopCamoufox,
} from "./camoufox-launcher";
import {
startProxyProcess,
stopAllProxyProcesses,
@@ -149,4 +154,277 @@ program
}
});
// Command for Camoufox anti-detect browser
program
.command("camoufox")
.argument("<action>", "launch, stop, list, or open-url for Camoufox browser")
.requiredOption("--executable-path <path>", "path to Camoufox executable")
.requiredOption("--profile-path <path>", "path to browser profile directory")
.option("--url <url>", "URL to open")
.option("--id <id>", "Camoufox instance ID (for stop/open-url actions)")
// Operating system fingerprinting
.option(
"--os <os>",
"OS to emulate (windows, macos, linux, or comma-separated list)",
)
// Blocking options
.option("--block-images", "block all images")
.option("--block-webrtc", "block WebRTC entirely")
.option("--block-webgl", "block WebGL")
// Security options
.option("--disable-coop", "disable Cross-Origin-Opener-Policy")
// Geolocation and IP
.option(
"--geoip <ip>",
"IP address for geolocation spoofing (or 'auto' for automatic)",
)
.option("--country <country>", "country code for geolocation")
.option("--timezone <timezone>", "timezone to spoof")
.option("--latitude <lat>", "latitude for geolocation", parseFloat)
.option("--longitude <lng>", "longitude for geolocation", parseFloat)
// UI and behavior
.option(
"--humanize [duration]",
"humanize cursor movement (optional max duration in seconds)",
(val) => (val ? parseFloat(val) : true),
)
.option("--headless", "run in headless mode")
// Localization
.option("--locale <locale>", "locale(s) to use (comma-separated)")
// Extensions and fonts
.option("--addons <addons>", "Firefox addons to load (comma-separated paths)")
.option("--fonts <fonts>", "additional fonts to load (comma-separated)")
.option("--custom-fonts-only", "use only custom fonts, exclude OS fonts")
.option(
"--exclude-addons <addons>",
"default addons to exclude (comma-separated)",
)
// Screen and window
.option("--screen-min-width <width>", "minimum screen width", parseInt)
.option("--screen-max-width <width>", "maximum screen width", parseInt)
.option("--screen-min-height <height>", "minimum screen height", parseInt)
.option("--screen-max-height <height>", "maximum screen height", parseInt)
.option("--window-width <width>", "fixed window width", parseInt)
.option("--window-height <height>", "fixed window height", parseInt)
// Advanced options
.option("--ff-version <version>", "Firefox version to emulate", parseInt)
.option("--main-world-eval", "enable main world script evaluation")
.option("--webgl-vendor <vendor>", "WebGL vendor string")
.option("--webgl-renderer <renderer>", "WebGL renderer string")
// Proxy
.option(
"--proxy <proxy>",
"proxy URL (protocol://[username:password@]host:port)",
)
// Cache and performance
.option("--disable-cache", "disable browser cache (cache enabled by default)")
// Environment and debugging
.option("--virtual-display <display>", "virtual display number (e.g., :99)")
.option("--debug", "enable debug output")
.option("--args <args>", "additional browser arguments (comma-separated)")
.option("--env <env>", "environment variables (JSON string)")
// Firefox preferences
.option("--firefox-prefs <prefs>", "Firefox user preferences (JSON string)")
.description("launch and manage Camoufox anti-detect browser instances")
.action(async (action: string, options: any) => {
try {
if (action === "launch") {
// Validate required options
if (!options.executablePath || !options.profilePath) {
console.error(
"Error: --executable-path and --profile-path are required for launch",
);
process.exit(1);
return;
}
// Build Camoufox options
const camoufoxOptions: any = {
enable_cache: !options.disableCache, // Cache enabled by default as requested
};
// OS fingerprinting
if (options.os) {
camoufoxOptions.os = options.os.includes(",")
? options.os.split(",")
: options.os;
}
// Blocking options
if (options.blockImages) camoufoxOptions.block_images = true;
if (options.blockWebrtc) camoufoxOptions.block_webrtc = true;
if (options.blockWebgl) camoufoxOptions.block_webgl = true;
// Security options
if (options.disableCoop) camoufoxOptions.disable_coop = true;
// Geolocation
if (options.geoip) {
camoufoxOptions.geoip =
options.geoip === "auto" ? true : options.geoip;
}
if (options.latitude && options.longitude) {
camoufoxOptions.geolocation = {
latitude: options.latitude,
longitude: options.longitude,
accuracy: 100,
};
}
if (options.country) camoufoxOptions.country = options.country;
if (options.timezone) camoufoxOptions.timezone = options.timezone;
// UI and behavior
if (options.humanize) camoufoxOptions.humanize = options.humanize;
if (options.headless) camoufoxOptions.headless = true;
// Localization
if (options.locale) {
camoufoxOptions.locale = options.locale.includes(",")
? options.locale.split(",")
: options.locale;
}
// Extensions and fonts
if (options.addons) camoufoxOptions.addons = options.addons.split(",");
if (options.fonts) camoufoxOptions.fonts = options.fonts.split(",");
if (options.customFontsOnly) camoufoxOptions.custom_fonts_only = true;
if (options.excludeAddons)
camoufoxOptions.exclude_addons = options.excludeAddons.split(",");
// Screen and window
const screen: any = {};
if (options.screenMinWidth) screen.minWidth = options.screenMinWidth;
if (options.screenMaxWidth) screen.maxWidth = options.screenMaxWidth;
if (options.screenMinHeight) screen.minHeight = options.screenMinHeight;
if (options.screenMaxHeight) screen.maxHeight = options.screenMaxHeight;
if (Object.keys(screen).length > 0) camoufoxOptions.screen = screen;
if (options.windowWidth && options.windowHeight) {
camoufoxOptions.window = [options.windowWidth, options.windowHeight];
}
// Advanced options
if (options.ffVersion) camoufoxOptions.ff_version = options.ffVersion;
if (options.mainWorldEval) camoufoxOptions.main_world_eval = true;
if (options.webglVendor && options.webglRenderer) {
camoufoxOptions.webgl_config = [
options.webglVendor,
options.webglRenderer,
];
}
// Proxy
if (options.proxy) camoufoxOptions.proxy = options.proxy;
// Environment and debugging
if (options.virtualDisplay)
camoufoxOptions.virtual_display = options.virtualDisplay;
if (options.debug) camoufoxOptions.debug = true;
if (options.args) camoufoxOptions.args = options.args.split(",");
if (options.env) {
try {
camoufoxOptions.env = JSON.parse(options.env);
} catch (e) {
console.error("Invalid JSON for --env option");
process.exit(1);
return;
}
}
// Firefox preferences
if (options.firefoxPrefs) {
try {
camoufoxOptions.firefox_user_prefs = JSON.parse(
options.firefoxPrefs,
);
} catch (e) {
console.error("Invalid JSON for --firefox-prefs option");
process.exit(1);
return;
}
}
// Launch Camoufox
const config = await launchCamoufox(
options.executablePath,
options.profilePath,
camoufoxOptions,
options.url,
);
// Output the configuration as JSON for the Rust side to parse
console.log(
JSON.stringify({
id: config.id,
pid: config.pid,
executable_path: config.executablePath,
profile_path: config.profilePath,
url: config.url,
}),
);
process.exit(0);
} else if (action === "stop") {
if (!options.id) {
console.error("Error: --id is required for stop action");
process.exit(1);
return;
}
const success = await stopCamoufox(options.id);
console.log(JSON.stringify({ success }));
process.exit(0);
} else if (action === "list") {
const processes = listCamoufoxProcesses();
// Convert camelCase to snake_case for Rust compatibility
const rustCompatibleProcesses = processes.map((process) => ({
id: process.id,
pid: process.pid,
executable_path: process.executablePath,
profile_path: process.profilePath,
url: process.url,
}));
console.log(JSON.stringify(rustCompatibleProcesses));
process.exit(0);
} else if (action === "open-url") {
if (!options.id || !options.url) {
console.error(
"Error: --id and --url are required for open-url action",
);
process.exit(1);
return;
}
// This would require implementing URL opening in existing instance
// For now, we'll return an error as this feature would need additional implementation
console.error("open-url action is not yet implemented");
process.exit(1);
} else {
console.error(
"Invalid action. Use 'launch', 'stop', 'list', or 'open-url'",
);
process.exit(1);
}
} catch (error: unknown) {
console.error(
`Camoufox command failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
);
process.exit(1);
}
});
program.parse();
+5 -4
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.6.0",
"version": "0.7.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
@@ -33,6 +33,7 @@
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.6.0",
@@ -44,12 +45,12 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"next": "^15.3.4",
"next": "^15.3.5",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.5",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
@@ -65,7 +66,7 @@
"lint-staged": "^16.1.2",
"tailwindcss": "^4.1.11",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.3.4",
"tw-animate-css": "^1.3.5",
"typescript": "~5.8.3"
},
"packageManager": "pnpm@10.11.1",
+1278 -157
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,9 +1,9 @@
packages:
- "nodecar"
- nodecar
onlyBuiltDependencies:
- "@biomejs/biome"
- "@tailwindcss/oxide"
- '@biomejs/biome'
- '@tailwindcss/oxide'
- esbuild
- sharp
- sqlite3
- unrs-resolver
+51 -32
View File
@@ -124,9 +124,9 @@ dependencies = [
[[package]]
name = "async-channel"
version = "2.3.1"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a"
checksum = "16c74e56284d2188cabb6ad99603d1ace887a5d7e7b695d01b728155ed9ed427"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
@@ -496,9 +496,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.27"
version = "1.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc"
checksum = "4ad45f4f74e4e20eaa392913b7b33a7091c87e59628f4dd27888205ad888843c"
dependencies = [
"jobserver",
"libc",
@@ -971,7 +971,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -1123,7 +1123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1662,9 +1662,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.10"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5"
checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785"
dependencies = [
"atomic-waker",
"bytes",
@@ -2060,6 +2060,17 @@ dependencies = [
"generic-array",
]
[[package]]
name = "io-uring"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"libc",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -3491,9 +3502,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.20"
version = "0.12.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -3573,13 +3584,12 @@ dependencies = [
[[package]]
name = "rust-ini"
version = "0.21.1"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f"
checksum = "e7295b7ce3bf4806b419dc3420745998b447178b7005e2011947b38fc5aa6791"
dependencies = [
"cfg-if",
"ordered-multimap",
"trim-in-place",
]
[[package]]
@@ -3607,7 +3617,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -3700,6 +3710,18 @@ dependencies = [
"serde_json",
]
[[package]]
name = "schemars"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1375ba8ef45a6f15d83fa8748f1079428295d403d6ea991d09ab100155fbc06d"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "0.8.22"
@@ -3856,9 +3878,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.13.0"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42"
checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -3866,6 +3888,7 @@ dependencies = [
"indexmap 1.9.3",
"indexmap 2.9.0",
"schemars 0.9.0",
"schemars 1.0.3",
"serde",
"serde_derive",
"serde_json",
@@ -3875,9 +3898,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.13.0"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77"
checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f"
dependencies = [
"darling",
"proc-macro2",
@@ -3941,9 +3964,9 @@ dependencies = [
[[package]]
name = "shared_child"
version = "1.1.0"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2778001df1384cf20b6dc5a5a90f48da35539885edaaefd887f8d744e939c0b"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
"sigchld",
@@ -3958,9 +3981,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "sigchld"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1219ef50fc0fdb04fcc243e6aa27f855553434ffafe4fa26554efb78b5b4bf89"
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
dependencies = [
"libc",
"os_pipe",
@@ -4652,7 +4675,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -4758,17 +4781,19 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.45.1"
version = "1.46.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17"
dependencies = [
"backtrace",
"bytes",
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"tokio-macros",
"tracing",
@@ -4990,12 +5015,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "trim-in-place"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc"
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -5412,7 +5431,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "donutbrowser"
version = "0.6.0"
description = "Simple Yet Powerful Browser Orchestrator"
version = "0.7.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
default-run = "donutbrowser"
+1 -1
View File
@@ -2,7 +2,7 @@
Version=1.0
Type=Application
Name=Donut Browser
Comment=Simple Yet Powerful Browser Orchestrator
Comment=Simple Yet Powerful Anti-Detect Browser
Exec=donutbrowser %u
Icon=donutbrowser
StartupNotify=true
+272 -10
View File
@@ -9,21 +9,21 @@ use std::time::{SystemTime, UNIX_EPOCH};
use crate::browser::GithubRelease;
#[derive(Debug, Clone, PartialEq, Eq)]
struct VersionComponent {
major: u32,
minor: u32,
patch: u32,
pre_release: Option<PreRelease>,
pub struct VersionComponent {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub pre_release: Option<PreRelease>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PreRelease {
kind: PreReleaseKind,
number: Option<u32>,
pub struct PreRelease {
pub kind: PreReleaseKind,
pub number: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum PreReleaseKind {
pub enum PreReleaseKind {
Alpha,
Beta,
RC,
@@ -32,7 +32,7 @@ enum PreReleaseKind {
}
impl VersionComponent {
fn parse(version: &str) -> Self {
pub fn parse(version: &str) -> Self {
let version = version.trim();
// Handle special case for Zen Browser twilight releases
@@ -259,6 +259,10 @@ pub fn is_browser_version_nightly(
// Chromium builds are generally stable snapshots
false
}
"camoufox" => {
// For Camoufox, beta versions are actually the stable releases
false
}
_ => {
// Default fallback
is_nightly_version(version)
@@ -856,6 +860,31 @@ impl ApiClient {
}
/// Check if a Brave release has compatible assets for the given platform and architecture
fn has_compatible_camoufox_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> bool {
let (os_name, arch_name) = match (os, arch) {
("windows", "x64") => ("win", "x86_64"),
("windows", "arm64") => ("win", "arm64"),
("linux", "x64") => ("lin", "x86_64"),
("linux", "arm64") => ("lin", "arm64"),
("macos", "x64") => ("mac", "x86_64"),
("macos", "arm64") => ("mac", "arm64"),
_ => return false,
};
// Look for assets matching the pattern: camoufox-{version}-{release}-{os}.{arch}.zip
assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.starts_with("camoufox-")
&& name.contains(&format!("-{os_name}.{arch_name}.zip"))
&& name.ends_with(".zip")
})
}
fn has_compatible_brave_asset(
assets: &[crate::browser::GithubAsset],
os: &str,
@@ -996,6 +1025,128 @@ impl ApiClient {
)
}
pub async fn fetch_camoufox_releases_with_caching(
&self,
no_caching: bool,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
// Check cache first (unless bypassing)
if !no_caching {
if let Some(cached_releases) = self.load_cached_github_releases("camoufox") {
println!(
"Using cached Camoufox releases, count: {}",
cached_releases.len()
);
return Ok(cached_releases);
}
}
println!("Fetching Camoufox releases from GitHub API...");
let url = format!(
"{}/repos/daijro/camoufox/releases?per_page=100",
self.github_api_base
);
let response = self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
if !response.status().is_success() {
return Err(format!("GitHub API returned status: {}", response.status()).into());
}
// Get the response text first for better error reporting
let response_text = response.text().await?;
// Try to parse the JSON with better error handling
let releases: Vec<GithubRelease> = match serde_json::from_str(&response_text) {
Ok(releases) => releases,
Err(e) => {
eprintln!("Failed to parse GitHub API response for Camoufox releases:");
eprintln!("Error: {e}");
eprintln!(
"Response text (first 500 chars): {}",
if response_text.len() > 500 {
&response_text[..500]
} else {
&response_text
}
);
return Err(format!("Failed to parse GitHub API response: {e}").into());
}
};
println!(
"Fetched {} total Camoufox releases from GitHub",
releases.len()
);
// Get platform info to filter appropriate releases
let (os, arch) = Self::get_platform_info();
println!("Filtering for platform: {os}/{arch}");
// Filter releases that have assets compatible with the current platform
let mut compatible_releases: Vec<GithubRelease> = releases
.into_iter()
.enumerate()
.filter_map(|(i, release)| {
let has_compatible = self.has_compatible_camoufox_asset(&release.assets, &os, &arch);
if !has_compatible {
println!(
"Release {} ({}) has no compatible assets for {}/{}",
i, release.tag_name, os, arch
);
println!(
" Available assets: {:?}",
release.assets.iter().map(|a| &a.name).collect::<Vec<_>>()
);
}
if has_compatible {
Some(release)
} else {
None
}
})
.collect();
println!(
"After platform filtering: {} compatible releases",
compatible_releases.len()
);
// Sort by version (latest first) with debugging
println!(
"Before sorting: {:?}",
compatible_releases
.iter()
.map(|r| &r.tag_name)
.take(10)
.collect::<Vec<_>>()
);
sort_github_releases(&mut compatible_releases);
println!(
"After sorting: {:?}",
compatible_releases
.iter()
.map(|r| &r.tag_name)
.take(10)
.collect::<Vec<_>>()
);
// Cache the results (unless bypassing cache)
if !no_caching {
if let Err(e) = self.save_cached_github_releases("camoufox", &compatible_releases) {
eprintln!("Failed to cache Camoufox releases: {e}");
} else {
println!("Cached {} Camoufox releases", compatible_releases.len());
}
}
Ok(compatible_releases)
}
pub async fn fetch_tor_releases_with_caching(
&self,
no_caching: bool,
@@ -1798,4 +1949,115 @@ mod tests {
let result = client.fetch_zen_releases_with_caching(true).await;
assert!(result.is_err());
}
#[test]
fn test_camoufox_beta_version_parsing() {
// Test specific Camoufox beta versions that are causing issues
let v22 = VersionComponent::parse("135.0.5beta22");
let v24 = VersionComponent::parse("135.0.5beta24");
println!("v22: {v22:?}");
println!("v24: {v24:?}");
// v24 should be greater than v22
assert!(
v24 > v22,
"135.0.5beta24 should be greater than 135.0.5beta22"
);
// Test other beta version combinations
let v1 = VersionComponent::parse("135.0.5beta1");
let v2 = VersionComponent::parse("135.0.5beta2");
assert!(v2 > v1, "135.0.5beta2 should be greater than 135.0.5beta1");
// Test sorting of multiple versions
let mut versions = vec![
"135.0.5beta22".to_string(),
"135.0.5beta24".to_string(),
"135.0.5beta23".to_string(),
"135.0.5beta21".to_string(),
];
sort_versions(&mut versions);
println!("Sorted versions: {versions:?}");
// Should be sorted from newest to oldest
assert_eq!(versions[0], "135.0.5beta24");
assert_eq!(versions[1], "135.0.5beta23");
assert_eq!(versions[2], "135.0.5beta22");
assert_eq!(versions[3], "135.0.5beta21");
}
#[test]
fn test_camoufox_user_reported_versions() {
// Test the exact versions reported by the user: 135.0.1beta24 vs 135.0beta22
let v22 = VersionComponent::parse("135.0beta22");
let v24 = VersionComponent::parse("135.0.1beta24");
println!("User reported v22: {v22:?}");
println!("User reported v24: {v24:?}");
// 135.0.1beta24 should be greater than 135.0beta22 (newer patch version)
assert!(
v24 > v22,
"135.0.1beta24 should be greater than 135.0beta22, but got: v24={v24:?} vs v22={v22:?}"
);
// Test sorting of the exact user-reported versions
let mut versions = vec!["135.0beta22".to_string(), "135.0.1beta24".to_string()];
sort_versions(&mut versions);
println!("User reported sorted versions: {versions:?}");
// Should be sorted from newest to oldest
assert_eq!(
versions[0], "135.0.1beta24",
"135.0.1beta24 should be first (newest)"
);
assert_eq!(
versions[1], "135.0beta22",
"135.0beta22 should be second (older)"
);
}
#[test]
fn test_camoufox_version_classification() {
// Test that Camoufox beta versions are now correctly classified as stable (not nightly)
assert!(
!is_browser_version_nightly("camoufox", "135.0beta22", None),
"135.0beta22 should be classified as stable for Camoufox"
);
assert!(
!is_browser_version_nightly("camoufox", "135.0.1beta24", None),
"135.0.1beta24 should be classified as stable for Camoufox"
);
// Test with release names too - beta releases should be stable
assert!(
!is_browser_version_nightly("camoufox", "135.0beta22", Some("Release Beta 22")),
"Release with 'Beta' in name should be classified as stable for Camoufox"
);
// Test that stable versions are not classified as nightly
assert!(
!is_browser_version_nightly("camoufox", "135.0", None),
"135.0 should be classified as stable"
);
assert!(
!is_browser_version_nightly("camoufox", "135.0.1", None),
"135.0.1 should be classified as stable"
);
// Test alpha and RC versions are still considered nightly
assert!(
!is_browser_version_nightly("camoufox", "136.0alpha1", None),
"136.0alpha1 should not be classified as nightly/prerelease"
);
assert!(
!is_browser_version_nightly("camoufox", "136.0rc1", None),
"136.0rc1 should not be classified as nightly/prerelease"
);
}
}
+71 -24
View File
@@ -407,22 +407,17 @@ impl AutoUpdater {
}
fn is_version_newer(&self, version1: &str, version2: &str) -> bool {
self.compare_versions(version1, version2) == std::cmp::Ordering::Greater
// Use the proper VersionComponent comparison from api_client.rs
let version_a = crate::api_client::VersionComponent::parse(version1);
let version_b = crate::api_client::VersionComponent::parse(version2);
version_a > version_b
}
fn compare_versions(&self, version1: &str, version2: &str) -> std::cmp::Ordering {
// Basic semantic version comparison
let v1_parts = self.parse_version(version1);
let v2_parts = self.parse_version(version2);
v1_parts.cmp(&v2_parts)
}
fn parse_version(&self, version: &str) -> Vec<u32> {
version
.split(&['.', 'a', 'b', '-', '_'][..])
.filter_map(|part| part.parse::<u32>().ok())
.collect()
// Use the proper VersionComponent comparison from api_client.rs
let version_a = crate::api_client::VersionComponent::parse(version1);
let version_b = crate::api_client::VersionComponent::parse(version2);
version_a.cmp(&version_b)
}
fn get_auto_update_state_file(&self) -> PathBuf {
@@ -522,6 +517,7 @@ mod tests {
proxy_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
}
}
@@ -569,6 +565,68 @@ mod tests {
assert!(!updater.is_version_newer("1.0.0", "1.0.0"));
}
#[test]
fn test_camoufox_beta_version_comparison() {
let updater = AutoUpdater::new();
// Test the exact user-reported scenario: 135.0.1beta24 vs 135.0beta22
assert!(
updater.is_version_newer("135.0.1beta24", "135.0beta22"),
"135.0.1beta24 should be newer than 135.0beta22"
);
assert_eq!(
updater.compare_versions("135.0.1beta24", "135.0beta22"),
std::cmp::Ordering::Greater,
"135.0.1beta24 should compare as greater than 135.0beta22"
);
// Test other camoufox beta version combinations
assert!(
updater.is_version_newer("135.0.5beta24", "135.0.5beta22"),
"135.0.5beta24 should be newer than 135.0.5beta22"
);
assert!(
updater.is_version_newer("135.0.1beta1", "135.0beta1"),
"135.0.1beta1 should be newer than 135.0beta1 due to patch version"
);
// Test that older versions are not considered newer
assert!(
!updater.is_version_newer("135.0beta22", "135.0.1beta24"),
"135.0beta22 should NOT be newer than 135.0.1beta24"
);
}
#[test]
fn test_beta_version_ordering_comprehensive() {
let updater = AutoUpdater::new();
// Test various beta version patterns that could appear in camoufox
let test_cases = vec![
("135.0.1beta24", "135.0beta22", true), // User reported case
("135.0.5beta24", "135.0.5beta22", true), // Same patch, different beta
("135.1beta1", "135.0beta99", true), // Higher minor beats beta number
("136.0beta1", "135.9.9beta99", true), // Higher major beats everything
("135.0.1beta1", "135.0beta1", true), // Patch version matters
("135.0beta22", "135.0.1beta24", false), // Reverse of user case
];
for (newer, older, should_be_newer) in test_cases {
let result = updater.is_version_newer(newer, older);
assert_eq!(
result,
should_be_newer,
"Expected {} {} {} but got {}",
newer,
if should_be_newer { ">" } else { "<=" },
older,
if result { "true" } else { "false" }
);
}
}
#[test]
fn test_check_profile_update_stable_to_stable() {
let updater = AutoUpdater::new();
@@ -853,15 +911,4 @@ mod tests {
let loaded_state: AutoUpdateState = serde_json::from_str(&content).unwrap();
assert_eq!(loaded_state.pending_updates.len(), 0);
}
#[test]
fn test_parse_version() {
let updater = AutoUpdater::new();
assert_eq!(updater.parse_version("1.2.3"), vec![1, 2, 3]);
assert_eq!(updater.parse_version("1.2.3-alpha"), vec![1, 2, 3]);
assert_eq!(updater.parse_version("1.2.3a1"), vec![1, 2, 3, 1]);
assert_eq!(updater.parse_version("1.2.3b2"), vec![1, 2, 3, 2]);
assert_eq!(updater.parse_version("10.0.0"), vec![10, 0, 0]);
}
}
+103 -1
View File
@@ -19,6 +19,7 @@ pub enum BrowserType {
Brave,
Zen,
TorBrowser,
Camoufox,
}
impl BrowserType {
@@ -31,6 +32,7 @@ impl BrowserType {
BrowserType::Brave => "brave",
BrowserType::Zen => "zen",
BrowserType::TorBrowser => "tor-browser",
BrowserType::Camoufox => "camoufox",
}
}
@@ -43,6 +45,7 @@ impl BrowserType {
"brave" => Ok(BrowserType::Brave),
"zen" => Ok(BrowserType::Zen),
"tor-browser" => Ok(BrowserType::TorBrowser),
"camoufox" => Ok(BrowserType::Camoufox),
_ => Err(format!("Unknown browser type: {s}")),
}
}
@@ -89,6 +92,7 @@ mod macos {
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.starts_with("camoufox")
|| name.contains("Browser")
})
.map(|entry| entry.path())
@@ -192,6 +196,12 @@ mod linux {
browser_subdir.join("firefox-bin"),
]
}
BrowserType::Camoufox => {
vec![
browser_subdir.join("camoufox"),
browser_subdir.join("camoufox-bin"),
]
}
_ => vec![],
};
@@ -274,6 +284,12 @@ mod linux {
browser_subdir.join("firefox"),
]
}
BrowserType::Camoufox => {
vec![
browser_subdir.join("camoufox-bin"),
browser_subdir.join("camoufox"),
]
}
_ => vec![],
};
@@ -358,6 +374,7 @@ mod windows {
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.starts_with("camoufox")
|| name.contains("browser")
{
return Ok(path);
@@ -436,6 +453,7 @@ mod windows {
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.starts_with("camoufox")
|| name.contains("browser")
{
return true;
@@ -532,7 +550,10 @@ impl Browser for FirefoxBrowser {
BrowserType::MullvadBrowser | BrowserType::TorBrowser => {
args.push("-no-remote".to_string());
}
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
BrowserType::Firefox
| BrowserType::FirefoxDeveloper
| BrowserType::Zen
| BrowserType::Camoufox => {
// Don't use -no-remote so we can communicate with existing instances
}
_ => {}
@@ -693,6 +714,81 @@ impl Browser for ChromiumBrowser {
}
}
pub struct CamoufoxBrowser;
impl CamoufoxBrowser {
pub fn new() -> Self {
Self
}
}
impl Browser for CamoufoxBrowser {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::get_firefox_executable_path(install_dir);
#[cfg(target_os = "linux")]
return linux::get_firefox_executable_path(install_dir, &BrowserType::Camoufox);
#[cfg(target_os = "windows")]
return windows::get_firefox_executable_path(install_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
&self,
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
url: Option<String>,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// For Camoufox, we handle launching through the camoufox launcher
// This method won't be used directly, but we provide basic Firefox args as fallback
let mut args = vec![
"-profile".to_string(),
profile_path.to_string(),
"-no-remote".to_string(),
];
if let Some(url) = url {
args.push(url);
}
Ok(args)
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let install_dir = binaries_dir.join("camoufox").join(version);
#[cfg(target_os = "macos")]
return macos::is_firefox_version_downloaded(&install_dir);
#[cfg(target_os = "linux")]
return linux::is_firefox_version_downloaded(&install_dir, &BrowserType::Camoufox);
#[cfg(target_os = "windows")]
return windows::is_firefox_version_downloaded(&install_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
false
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
// Factory function to create browser instances
pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
match browser_type {
@@ -702,6 +798,7 @@ pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
| BrowserType::Zen
| BrowserType::TorBrowser => Box::new(FirefoxBrowser::new(browser_type)),
BrowserType::Chromium | BrowserType::Brave => Box::new(ChromiumBrowser::new(browser_type)),
BrowserType::Camoufox => Box::new(CamoufoxBrowser::new()),
}
}
@@ -778,6 +875,7 @@ mod tests {
assert_eq!(BrowserType::Brave.as_str(), "brave");
assert_eq!(BrowserType::Zen.as_str(), "zen");
assert_eq!(BrowserType::TorBrowser.as_str(), "tor-browser");
assert_eq!(BrowserType::Camoufox.as_str(), "camoufox");
// Test from_str
assert_eq!(
@@ -802,6 +900,10 @@ mod tests {
BrowserType::from_str("tor-browser").unwrap(),
BrowserType::TorBrowser
);
assert_eq!(
BrowserType::from_str("camoufox").unwrap(),
BrowserType::Camoufox
);
// Test invalid browser type
assert!(BrowserType::from_str("invalid").is_err());
+480 -157
View File
@@ -13,6 +13,7 @@ use crate::browser::{create_browser, BrowserType, ProxySettings};
use crate::browser_version_service::{
BrowserVersionInfo, BrowserVersionService, BrowserVersionsResult,
};
use crate::camoufox::CamoufoxConfig;
use crate::download::{DownloadProgress, Downloader};
use crate::downloaded_browsers::DownloadedBrowsersRegistry;
use crate::extraction::Extractor;
@@ -31,13 +32,15 @@ pub struct BrowserProfile {
pub last_launch: Option<u64>,
#[serde(default = "default_release_type")]
pub release_type: String, // "stable" or "nightly"
#[serde(default)]
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
}
fn default_release_type() -> String {
"stable".to_string()
}
// Global state to track currently downloading browsers
// Global state to track currently downloading browser-version pairs
lazy_static::lazy_static! {
static ref DOWNLOADING_BROWSERS: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
}
@@ -1347,6 +1350,7 @@ impl BrowserRunner {
version: &str,
release_type: &str,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
println!("Attempting to create profile: {name}");
@@ -1379,6 +1383,7 @@ impl BrowserRunner {
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
camoufox_config: camoufox_config.clone(),
};
// Save profile info
@@ -1772,10 +1777,109 @@ impl BrowserRunner {
pub async fn launch_browser(
&self,
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: Option<String>,
local_proxy_settings: Option<&ProxySettings>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Handle camoufox profiles specially
if profile.browser == "camoufox" {
if let Some(mut camoufox_config) = profile.camoufox_config.clone() {
// Handle proxy settings for camoufox
if let Some(proxy_id) = &profile.proxy_id {
if let Some(stored_proxy) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) {
println!("Starting proxy for Camoufox profile: {}", profile.name);
// Start the proxy and get local proxy settings
let local_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
&stored_proxy,
0, // Use 0 as temporary PID, will be updated later
Some(&profile.name),
)
.await
.map_err(|e| format!("Failed to start proxy for Camoufox: {e}"))?;
// Format proxy URL for camoufox
let proxy_url = format!(
"{}://{}:{}",
if stored_proxy.proxy_type == "socks5" || stored_proxy.proxy_type == "socks4" {
&stored_proxy.proxy_type
} else {
"http"
},
local_proxy.host,
local_proxy.port
);
// Add username and password if available
let proxy_url = if let (Some(username), Some(password)) =
(&stored_proxy.username, &stored_proxy.password)
{
format!(
"{}://{}:{}@{}:{}",
if stored_proxy.proxy_type == "socks5" || stored_proxy.proxy_type == "socks4" {
&stored_proxy.proxy_type
} else {
"http"
},
username,
password,
local_proxy.host,
local_proxy.port
)
} else {
proxy_url
};
// Set proxy in camoufox config
camoufox_config.proxy = Some(proxy_url);
println!("Configured proxy for Camoufox: {:?}", camoufox_config.proxy);
}
}
// Use the camoufox launcher
let camoufox_result = crate::camoufox::launch_camoufox_profile(
app_handle.clone(),
profile.clone(),
camoufox_config,
url,
)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to launch camoufox: {e}").into()
})?;
// Update proxy with actual PID if proxy was started
if let Some(pid) = camoufox_result.pid {
if profile.proxy_id.is_some() {
if let Err(e) = PROXY_MANAGER.update_proxy_pid(0, pid) {
println!("Warning: Failed to update proxy PID: {e}");
}
}
}
// Update profile with the process info from camoufox result
let mut updated_profile = profile.clone();
updated_profile.process_id = camoufox_result.pid;
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
// Save the updated profile
self.save_process_info(&updated_profile)?;
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
}
return Ok(updated_profile);
} else {
return Err("Camoufox profile missing configuration".into());
}
}
// Create browser instance
let browser_type = BrowserType::from_str(&profile.browser)
.map_err(|_| format!("Invalid browser type: {}", profile.browser))?;
@@ -1853,91 +1957,85 @@ impl BrowserRunner {
// For TOR and Mullvad browsers, we need to find the actual browser process
// because they use launcher scripts that spawn the real browser process
let actual_pid = if matches!(
let mut actual_pid = launcher_pid;
if matches!(
browser_type,
BrowserType::TorBrowser | BrowserType::MullvadBrowser
) {
println!("Waiting for TOR/Mullvad browser to fully start...");
// Wait a bit for the browser to fully start
// Wait a moment for the actual browser process to start
tokio::time::sleep(tokio::time::Duration::from_millis(3000)).await;
// Search for the actual browser process
// Find the actual browser process
let system = System::new_all();
let mut found_pid: Option<u32> = None;
for (pid, process) in system.processes() {
let process_name = process.name().to_str().unwrap_or("");
let process_cmd = process.cmd();
let pid_u32 = pid.as_u32();
// Try multiple times to find the process as it might take time to start
for attempt in 1..=5 {
println!("Attempt {attempt} to find actual browser process...");
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.len() >= 2 {
// Check if this is the right browser executable
let exe_name = process.name().to_string_lossy().to_lowercase();
let is_correct_browser = match profile.browser.as_str() {
"mullvad-browser" => {
self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser")
}
"tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "tor-browser"),
_ => false,
};
if !is_correct_browser {
continue;
}
// Check for profile path match
let profile_path_match = cmd.iter().any(|s| {
let arg = s.to_str().unwrap_or("");
arg == profile_data_path.to_string_lossy()
|| arg == format!("-profile={}", profile_data_path.to_string_lossy())
|| (arg == "-profile"
&& cmd
.iter()
.any(|s2| s2.to_str().unwrap_or("") == profile_data_path.to_string_lossy()))
});
if profile_path_match {
found_pid = Some(pid.as_u32());
println!(
"Found actual browser process with PID: {} for profile: {}",
pid.as_u32(),
profile.name
);
break;
}
}
// Skip if this is the launcher process itself
if pid_u32 == launcher_pid {
continue;
}
if found_pid.is_some() {
if self.is_tor_or_mullvad_browser(process_name, process_cmd, &profile.browser) {
println!(
"Found actual {} browser process: PID {} ({})",
profile.browser, pid_u32, process_name
);
actual_pid = pid_u32;
break;
}
// Wait before next attempt
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
}
}
found_pid.unwrap_or(launcher_pid)
} else {
// For other browsers, the launcher PID is usually the actual browser PID
launcher_pid
};
// Update profile with process info
// Update profile with process info and save
let mut updated_profile = profile.clone();
updated_profile.process_id = Some(actual_pid);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
// Save the updated profile
self
.save_process_info(&updated_profile)
.expect("Failed to save process info");
self.save_process_info(&updated_profile)?;
// Apply proxy settings if needed (for Firefox-based browsers)
if profile.proxy_id.is_some()
&& matches!(
browser_type,
BrowserType::Firefox
| BrowserType::FirefoxDeveloper
| BrowserType::Zen
| BrowserType::TorBrowser
| BrowserType::MullvadBrowser
)
{
// Proxy settings for Firefox-based browsers are applied via user.js file
// which is already handled in the profile creation process
}
// Start proxy if configured and needed (for Chromium-based browsers)
if let Some(proxy_id) = &profile.proxy_id {
if let Some(stored_proxy) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) {
println!("Starting proxy for profile: {}", profile.name);
match PROXY_MANAGER
.start_proxy(
app_handle.clone(),
&stored_proxy,
actual_pid,
Some(&profile.name),
)
.await
{
Ok(_) => println!("Proxy started successfully for profile: {}", profile.name),
Err(e) => println!("Warning: Failed to start proxy: {e}"),
}
}
}
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
}
println!(
"Browser launched successfully with PID: {} for profile: {}",
actual_pid, profile.name
);
Ok(updated_profile)
}
@@ -1948,8 +2046,43 @@ impl BrowserRunner {
url: &str,
_internal_proxy_settings: Option<&ProxySettings>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Use the comprehensive browser status check
let is_running = self.check_browser_status(app_handle, profile).await?;
// Handle camoufox profiles specially
if profile.browser == "camoufox" {
let camoufox_launcher = crate::camoufox::CamoufoxLauncher::new(app_handle.clone());
// Get the profile path based on the UUID
let profiles_dir = self.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_path_str = profile_data_path.to_string_lossy();
// Check if the process is running
match camoufox_launcher
.find_camoufox_by_profile(&profile_path_str)
.await
{
Ok(Some(_camoufox_process)) => {
println!(
"Opening URL in existing Camoufox process for profile: {}",
profile.name
);
// For Camoufox, we need to launch a new instance with the URL since nodecar doesn't support
// opening URLs in existing instances. This is a limitation of the anti-detect architecture.
return Err("Camoufox does not support opening URLs in existing instances. Please close the browser and relaunch it with the new URL.".into());
}
Ok(None) => {
return Err("Camoufox browser is not running".into());
}
Err(e) => {
return Err(format!("Error checking Camoufox process: {e}").into());
}
}
}
// Use the comprehensive browser status check for non-camoufox browsers
let is_running = self
.check_browser_status(app_handle.clone(), profile)
.await?;
if !is_running {
return Err("Browser is not running".into());
@@ -2105,6 +2238,10 @@ impl BrowserRunner {
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform".into());
}
BrowserType::Camoufox => {
// This should never be reached due to the early return above, but handle it just in case
Err("Camoufox does not support opening URLs in existing instances".into())
}
}
}
@@ -2159,7 +2296,7 @@ impl BrowserRunner {
}
match self
.open_url_in_existing_browser(
app_handle,
app_handle.clone(),
&final_profile,
url_ref,
internal_proxy_settings,
@@ -2188,7 +2325,7 @@ impl BrowserRunner {
final_profile.browser
);
// Fallback to launching a new instance for other browsers
self.launch_browser(&final_profile, url, internal_proxy_settings).await
self.launch_browser(app_handle.clone(), &final_profile, url, internal_proxy_settings).await
}
}
}
@@ -2197,7 +2334,12 @@ impl BrowserRunner {
// This case shouldn't happen since we checked is_some() above, but handle it gracefully
println!("URL was unexpectedly None, launching new browser instance");
self
.launch_browser(&final_profile, url, internal_proxy_settings)
.launch_browser(
app_handle.clone(),
&final_profile,
url,
internal_proxy_settings,
)
.await
}
} else {
@@ -2208,7 +2350,12 @@ impl BrowserRunner {
println!("Launching new browser instance - no URL provided");
}
self
.launch_browser(&final_profile, url, internal_proxy_settings)
.launch_browser(
app_handle.clone(),
&final_profile,
url,
internal_proxy_settings,
)
.await
}
}
@@ -2242,9 +2389,15 @@ impl BrowserRunner {
Ok(profile)
}
fn save_process_info(&self, profile: &BrowserProfile) -> Result<(), Box<dyn std::error::Error>> {
fn save_process_info(
&self,
profile: &BrowserProfile,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Use the regular save_profile method which handles the UUID structure
self.save_profile(profile)
self.save_profile(profile).map_err(|e| {
let error_string = e.to_string();
Box::new(std::io::Error::other(error_string)) as Box<dyn std::error::Error + Send + Sync>
})
}
pub fn delete_profile(&self, profile_name: &str) -> Result<(), Box<dyn std::error::Error>> {
@@ -2294,6 +2447,79 @@ impl BrowserRunner {
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
// Handle camoufox profiles specially using the camoufox launcher
if profile.browser == "camoufox" {
let camoufox_launcher = crate::camoufox::CamoufoxLauncher::new(app_handle.clone());
// Get the profile path based on the UUID
let profiles_dir = self.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_path_str = profile_data_path.to_string_lossy();
println!("Checking Camoufox status for profile: {}", profile.name);
println!("Profile UUID: {}", profile.id);
println!("Profile path: {profile_path_str}");
match camoufox_launcher
.find_camoufox_by_profile(&profile_path_str)
.await
{
Ok(Some(camoufox_process)) => {
// Found a running camoufox process for this profile
println!(
"Found running Camoufox process for profile {}: {:?}",
profile.name, camoufox_process
);
// Update the profile with the current PID if it's different
if let Some(pid) = camoufox_process.pid {
if profile.process_id != Some(pid) {
let mut updated_profile = profile.clone();
updated_profile.process_id = Some(pid);
if let Err(e) = self.save_profile(&updated_profile) {
println!("Warning: Failed to update profile PID: {e}");
} else {
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
}
}
}
}
return Ok(true);
}
Ok(None) => {
// No running camoufox process found for this profile
println!(
"No running Camoufox process found for profile: {}",
profile.name
);
// Clear the PID if one was stored
if profile.process_id.is_some() {
let mut updated_profile = profile.clone();
updated_profile.process_id = None;
if let Err(e) = self.save_profile(&updated_profile) {
println!("Warning: Failed to clear profile PID: {e}");
} else {
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
}
}
}
return Ok(false);
}
Err(e) => {
println!("Error checking Camoufox status: {e}");
return Ok(false);
}
}
}
// For non-camoufox browsers, use the existing logic
let mut inner_profile = profile.clone();
let system = System::new_all();
let mut is_running = false;
@@ -2416,67 +2642,21 @@ impl BrowserRunner {
if let Some(pid) = found_pid {
if inner_profile.process_id != Some(pid) {
inner_profile.process_id = Some(pid);
if let Err(e) = self.save_process_info(&inner_profile) {
println!("Warning: Failed to update process info: {e}");
} else {
println!(
"Updated process ID for profile '{}' to: {}",
inner_profile.name, pid
);
if let Err(e) = self.save_profile(&inner_profile) {
println!("Warning: Failed to update profile with new PID: {e}");
}
}
} else if is_running {
println!("Browser is running but no PID found - this shouldn't happen");
} else {
// Browser is not running, clear the PID if it was set
if inner_profile.process_id.is_some() {
inner_profile.process_id = None;
if let Err(e) = self.save_process_info(&inner_profile) {
println!("Warning: Failed to clear process info: {e}");
} else {
println!("Cleared process ID for profile '{}'", inner_profile.name);
}
} else if inner_profile.process_id.is_some() {
// Clear the PID if no process found
inner_profile.process_id = None;
if let Err(e) = self.save_profile(&inner_profile) {
println!("Warning: Failed to clear profile PID: {e}");
}
}
// Handle proxy management based on browser status
if let Some(proxy_id) = &inner_profile.proxy_id {
if let Some(proxy) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) {
if is_running {
// Browser is running, check if proxy is active
let proxy_active = PROXY_MANAGER
.get_proxy_settings(inner_profile.process_id.unwrap_or(0))
.is_some();
if !proxy_active {
// Browser is running but proxy is not - restart the proxy
match PROXY_MANAGER
.start_proxy(
app_handle,
&proxy,
inner_profile.process_id.unwrap(),
Some(&inner_profile.name),
)
.await
{
Ok(_) => {
println!("Restarted proxy for profile {}", inner_profile.name);
}
Err(e) => {
eprintln!(
"Failed to restart proxy for profile {}: {}",
inner_profile.name, e
);
}
}
}
} else {
// Browser is not running, stop the proxy if it exists
if let Some(pid) = profile.process_id {
let _ = PROXY_MANAGER.stop_proxy(app_handle, pid).await;
}
}
}
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &inner_profile) {
println!("Warning: Failed to emit profile update event: {e}");
}
Ok(is_running)
@@ -2487,7 +2667,87 @@ impl BrowserRunner {
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Get the current process ID
// Handle camoufox profiles specially
if profile.browser == "camoufox" {
let camoufox_launcher = crate::camoufox::CamoufoxLauncher::new(app_handle.clone());
// Get the profile path based on the UUID
let profiles_dir = self.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_path_str = profile_data_path.to_string_lossy();
println!(
"Attempting to kill Camoufox process for profile: {}",
profile.name
);
println!("Profile UUID: {}", profile.id);
println!("Profile path: {profile_path_str}");
match camoufox_launcher
.find_camoufox_by_profile(&profile_path_str)
.await
{
Ok(Some(camoufox_process)) => {
println!(
"Found running Camoufox process for profile {}: {:?}",
profile.name, camoufox_process
);
// Stop the camoufox process using the launcher
match camoufox_launcher.stop_camoufox(&camoufox_process.id).await {
Ok(stopped) => {
if stopped {
println!(
"Successfully stopped Camoufox process: {}",
camoufox_process.id
);
} else {
println!("Failed to stop Camoufox process: {}", camoufox_process.id);
return Err("Failed to stop Camoufox process".into());
}
}
Err(e) => {
println!("Error stopping Camoufox process: {e}");
return Err(format!("Error stopping Camoufox process: {e}").into());
}
}
}
Ok(None) => {
println!(
"No running Camoufox process found for profile: {}",
profile.name
);
// Process might already be stopped, just clear the PID
}
Err(e) => {
println!("Error finding Camoufox process: {e}");
return Err(format!("Error finding Camoufox process: {e}").into());
}
}
// Stop proxy if one was running for this profile
if let Some(pid) = profile.process_id {
if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle.clone(), pid).await {
println!("Warning: Failed to stop proxy for Camoufox profile: {e}");
}
}
// Clear the process ID from the profile
let mut updated_profile = profile.clone();
updated_profile.process_id = None;
self
.save_process_info(&updated_profile)
.map_err(|e| format!("Failed to update profile: {e}"))?;
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
}
return Ok(());
}
// For non-camoufox browsers, use the existing logic
let pid = if let Some(pid) = profile.process_id {
pid
} else {
@@ -2554,7 +2814,7 @@ impl BrowserRunner {
println!("Attempting to kill browser process with PID: {pid}");
// Stop any associated proxy first
if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle, pid).await {
if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle.clone(), pid).await {
println!("Warning: Failed to stop proxy for PID {pid}: {e}");
}
@@ -2667,16 +2927,17 @@ impl BrowserRunner {
browser_str: String,
version: String,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Check if this browser type is already being downloaded
// Check if this browser-version pair is already being downloaded
let download_key = format!("{browser_str}-{version}");
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
if downloading.contains(&browser_str) {
if downloading.contains(&download_key) {
return Err(format!(
"Browser '{browser_str}' is already being downloaded. Please wait for the current download to complete."
"Browser '{browser_str}' version '{version}' is already being downloaded. Please wait for the current download to complete."
).into());
}
// Mark this browser as being downloaded
downloading.insert(browser_str.clone());
// Mark this browser-version pair as being downloaded
downloading.insert(download_key.clone());
}
let browser_type =
@@ -2762,10 +3023,10 @@ impl BrowserRunner {
// Clean up failed download
let _ = registry.cleanup_failed_download(&browser_str, &version);
let _ = registry.save();
// Remove browser from downloading set on error
// Remove browser-version pair from downloading set on error
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&browser_str);
downloading.remove(&download_key);
}
return Err(format!("Failed to download browser: {e}").into());
}
@@ -2794,10 +3055,10 @@ impl BrowserRunner {
// Clean up failed download
let _ = registry.cleanup_failed_download(&browser_str, &version);
let _ = registry.save();
// Remove browser from downloading set on error
// Remove browser-version pair from downloading set on error
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&browser_str);
downloading.remove(&download_key);
}
return Err(format!("Failed to extract browser: {e}").into());
}
@@ -2828,10 +3089,10 @@ impl BrowserRunner {
if !browser.is_version_downloaded(&version, &binaries_dir) {
let _ = registry.cleanup_failed_download(&browser_str, &version);
let _ = registry.save();
// Remove browser from downloading set on verification failure
// Remove browser-version pair from downloading set on verification failure
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&browser_str);
downloading.remove(&download_key);
}
return Err("Browser download completed but verification failed".into());
}
@@ -2850,6 +3111,26 @@ impl BrowserRunner {
.save()
.map_err(|e| format!("Failed to save registry: {e}"))?;
// If this is Camoufox, automatically download GeoIP database
if browser_str == "camoufox" {
use crate::geoip_downloader::GeoIPDownloader;
// Check if GeoIP database is already available
if !GeoIPDownloader::is_geoip_database_available() {
println!("Downloading GeoIP database for Camoufox...");
let geoip_downloader = GeoIPDownloader::new();
if let Err(e) = geoip_downloader.download_geoip_database(&app_handle).await {
eprintln!("Warning: Failed to download GeoIP database: {e}");
// Don't fail the browser download if GeoIP download fails
} else {
println!("GeoIP database downloaded successfully");
}
} else {
println!("GeoIP database already available");
}
}
// Emit completion
let progress = DownloadProgress {
browser: browser_str.clone(),
@@ -2863,10 +3144,10 @@ impl BrowserRunner {
};
let _ = app_handle.emit("download-progress", &progress);
// Remove browser from downloading set
// Remove browser-version pair from downloading set
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&browser_str);
downloading.remove(&download_key);
}
Ok(version)
@@ -2899,6 +3180,34 @@ impl BrowserRunner {
files_exist
}
/// Update camoufox configuration for a profile
pub fn update_camoufox_config(
&self,
profile_name: &str,
config: CamoufoxConfig,
) -> Result<(), Box<dyn std::error::Error>> {
let mut profiles = self.list_profiles()?;
// Find the profile to update
let profile = profiles
.iter_mut()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
// Ensure the profile is a camoufox profile
if profile.browser != "camoufox" {
return Err(format!("Profile '{profile_name}' is not a camoufox profile").into());
}
// Update the camoufox configuration
profile.camoufox_config = Some(config);
// Save the updated profile
self.save_profile(profile)?;
Ok(())
}
}
impl BrowserProfile {
@@ -2915,10 +3224,18 @@ pub fn create_browser_profile(
version: String,
release_type: String,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
) -> Result<BrowserProfile, String> {
let browser_runner = BrowserRunner::new();
browser_runner
.create_profile(&name, &browser, &version, &release_type, proxy_id)
.create_profile(
&name,
&browser,
&version,
&release_type,
proxy_id,
camoufox_config,
)
.map_err(|e| format!("Failed to create profile: {e}"))
}
@@ -3207,6 +3524,7 @@ pub fn create_browser_profile_new(
version: String,
release_type: String,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
) -> Result<BrowserProfile, String> {
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
@@ -3216,6 +3534,7 @@ pub fn create_browser_profile_new(
version,
release_type,
proxy_id,
camoufox_config,
)
}
@@ -3313,7 +3632,7 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
let profile = runner
.create_profile("Test Profile", "firefox", "139.0", "stable", None)
.create_profile("Test Profile", "firefox", "139.0", "stable", None, None)
.unwrap();
assert_eq!(profile.name, "Test Profile");
@@ -3342,6 +3661,7 @@ mod tests {
"139.0",
"stable",
None, // Tests now use separate proxy storage system
None, // No camoufox config for this test
)
.unwrap();
@@ -3355,7 +3675,7 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
let profile = runner
.create_profile("Test Save Load", "firefox", "139.0", "stable", None)
.create_profile("Test Save Load", "firefox", "139.0", "stable", None, None)
.unwrap();
// Save the profile
@@ -3375,7 +3695,7 @@ mod tests {
// Create profile
let _ = runner
.create_profile("Original Name", "firefox", "139.0", "stable", None)
.create_profile("Original Name", "firefox", "139.0", "stable", None, None)
.unwrap();
// Rename profile
@@ -3395,7 +3715,7 @@ mod tests {
// Create profile
let _ = runner
.create_profile("To Delete", "firefox", "139.0", "stable", None)
.create_profile("To Delete", "firefox", "139.0", "stable", None, None)
.unwrap();
// Verify profile exists
@@ -3422,6 +3742,7 @@ mod tests {
"139.0",
"stable",
None,
None,
)
.unwrap();
@@ -3444,13 +3765,13 @@ mod tests {
// Create multiple profiles
let _ = runner
.create_profile("Profile 1", "firefox", "139.0", "stable", None)
.create_profile("Profile 1", "firefox", "139.0", "stable", None, None)
.unwrap();
let _ = runner
.create_profile("Profile 2", "chromium", "1465660", "stable", None)
.create_profile("Profile 2", "chromium", "1465660", "stable", None, None)
.unwrap();
let _ = runner
.create_profile("Profile 3", "brave", "v1.81.9", "stable", None)
.create_profile("Profile 3", "brave", "v1.81.9", "stable", None, None)
.unwrap();
// List profiles
@@ -3469,10 +3790,10 @@ mod tests {
// Test that we can't rename to an existing profile name
let _ = runner
.create_profile("Profile 1", "firefox", "139.0", "stable", None)
.create_profile("Profile 1", "firefox", "139.0", "stable", None, None)
.unwrap();
let _ = runner
.create_profile("Profile 2", "firefox", "139.0", "stable", None)
.create_profile("Profile 2", "firefox", "139.0", "stable", None, None)
.unwrap();
// Try to rename profile2 to profile1's name (should fail)
@@ -3493,6 +3814,7 @@ mod tests {
"139.0",
"stable",
None,
None,
)
.unwrap();
@@ -3526,6 +3848,7 @@ mod tests {
"139.0",
"stable",
None, // Tests now use separate proxy storage system
None, // No camoufox config for this test
)
.unwrap();
+71
View File
@@ -87,6 +87,10 @@ impl BrowserVersionService {
Ok(true)
}
}
"camoufox" => {
// Camoufox supports all platforms and architectures according to the JS code
Ok(true)
}
_ => Err(format!("Unknown browser: {browser}").into()),
}
}
@@ -101,6 +105,7 @@ impl BrowserVersionService {
"brave",
"chromium",
"tor-browser",
"camoufox",
];
all_browsers
@@ -237,6 +242,7 @@ impl BrowserVersionService {
"brave" => self.fetch_brave_versions(true).await?,
"chromium" => self.fetch_chromium_versions(true).await?,
"tor-browser" => self.fetch_tor_versions(true).await?,
"camoufox" => self.fetch_camoufox_versions(true).await?,
_ => return Err(format!("Unsupported browser: {browser}").into()),
};
@@ -454,6 +460,27 @@ impl BrowserVersionService {
})
.collect()
}
"camoufox" => {
let releases = self.fetch_camoufox_releases_detailed(true).await?;
merged_versions
.into_iter()
.map(|version| {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.is_nightly,
date: release.published_at.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: false, // Camoufox usually stable releases
date: "".to_string(),
}
}
})
.collect()
}
_ => {
return Err(format!("Unsupported browser: {browser}").into());
}
@@ -727,6 +754,32 @@ impl BrowserVersionService {
is_archive,
})
}
"camoufox" => {
// Camoufox downloads from GitHub releases with pattern: camoufox-{version}-{release}-{os}.{arch}.zip
let (os_name, arch_name) = match (&os[..], &arch[..]) {
("windows", "x64") => ("win", "x86_64"),
("windows", "arm64") => ("win", "arm64"),
("linux", "x64") => ("lin", "x86_64"),
("linux", "arm64") => ("lin", "arm64"),
("macos", "x64") => ("mac", "x86_64"),
("macos", "arm64") => ("mac", "arm64"),
_ => {
return Err(
format!("Unsupported platform/architecture for Camoufox: {os}/{arch}").into(),
)
}
};
// Note: We provide a placeholder URL here since Camoufox requires dynamic resolution
// The actual URL will be resolved in download.rs resolve_download_url
Ok(DownloadInfo {
url: format!(
"https://github.com/daijro/camoufox/releases/download/{version}/camoufox-{{version}}-{{release}}-{os_name}.{arch_name}.zip"
),
filename: format!("camoufox-{version}-{os_name}.{arch_name}.zip"),
is_archive: true,
})
}
_ => Err(format!("Unsupported browser: {browser}").into()),
}
}
@@ -889,6 +942,24 @@ impl BrowserVersionService {
.fetch_tor_releases_with_caching(no_caching)
.await
}
async fn fetch_camoufox_versions(
&self,
no_caching: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_camoufox_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.tag_name).collect())
}
async fn fetch_camoufox_releases_detailed(
&self,
no_caching: bool,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self
.api_client
.fetch_camoufox_releases_with_caching(no_caching)
.await
}
}
#[cfg(test)]
+607
View File
@@ -0,0 +1,607 @@
use crate::browser_runner::BrowserProfile;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::AppHandle;
use tauri_plugin_shell::ShellExt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CamoufoxConfig {
pub os: Option<Vec<String>>,
pub block_images: Option<bool>,
pub block_webrtc: Option<bool>,
pub block_webgl: Option<bool>,
pub disable_coop: Option<bool>,
pub geoip: Option<serde_json::Value>, // Can be String or bool
pub country: Option<String>,
pub timezone: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub humanize: Option<bool>,
pub humanize_duration: Option<f64>,
pub headless: Option<bool>,
pub locale: Option<Vec<String>>,
pub addons: Option<Vec<String>>,
pub fonts: Option<Vec<String>>,
pub custom_fonts_only: Option<bool>,
pub exclude_addons: Option<Vec<String>>,
pub screen_min_width: Option<u32>,
pub screen_max_width: Option<u32>,
pub screen_min_height: Option<u32>,
pub screen_max_height: Option<u32>,
pub window_width: Option<u32>,
pub window_height: Option<u32>,
pub ff_version: Option<u32>,
pub main_world_eval: Option<bool>,
pub webgl_vendor: Option<String>,
pub webgl_renderer: Option<String>,
pub proxy: Option<String>,
pub enable_cache: Option<bool>,
pub virtual_display: Option<String>,
pub debug: Option<bool>,
pub additional_args: Option<Vec<String>>,
pub env_vars: Option<HashMap<String, String>>,
pub firefox_prefs: Option<HashMap<String, serde_json::Value>>,
}
impl Default for CamoufoxConfig {
fn default() -> Self {
Self {
os: None,
block_images: None,
block_webrtc: None,
block_webgl: None,
disable_coop: None,
geoip: None,
country: None,
timezone: None,
latitude: None,
longitude: None,
humanize: None,
humanize_duration: None,
headless: None,
locale: None,
addons: None,
fonts: None,
custom_fonts_only: None,
exclude_addons: None,
screen_min_width: None,
screen_max_width: None,
screen_min_height: None,
screen_max_height: None,
window_width: None,
window_height: None,
ff_version: None,
main_world_eval: None,
webgl_vendor: None,
webgl_renderer: None,
proxy: None,
enable_cache: Some(true), // Cache enabled by default
virtual_display: None,
debug: None,
additional_args: None,
env_vars: None,
firefox_prefs: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(non_snake_case)]
pub struct CamoufoxLaunchResult {
pub id: String,
pub pid: Option<u32>,
#[serde(alias = "executable_path")]
pub executablePath: String,
#[serde(alias = "profile_path")]
pub profilePath: String,
pub url: Option<String>,
}
pub struct CamoufoxLauncher {
app_handle: AppHandle,
}
impl CamoufoxLauncher {
pub fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
/// Launch Camoufox browser with the specified configuration
pub async fn launch_camoufox(
&self,
executable_path: &str,
profile_path: &str,
config: &CamoufoxConfig,
url: Option<&str>,
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
println!("Launching Camoufox with executable: {executable_path}");
println!("Profile path: {profile_path}");
println!("URL: {url:?}");
// Use Tauri's sidecar to call nodecar
let mut sidecar = self
.app_handle
.shell()
.sidecar("nodecar")
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
.arg("camoufox")
.arg("launch")
.arg("--executable-path")
.arg(executable_path)
.arg("--profile-path")
.arg(profile_path);
// Add URL if provided
if let Some(url) = url {
sidecar = sidecar.arg("--url").arg(url);
}
// Add configuration options
if let Some(os_list) = &config.os {
sidecar = sidecar.arg("--os").arg(os_list.join(","));
}
if config.block_images.unwrap_or(false) {
sidecar = sidecar.arg("--block-images");
}
if config.block_webrtc.unwrap_or(false) {
sidecar = sidecar.arg("--block-webrtc");
}
if config.block_webgl.unwrap_or(false) {
sidecar = sidecar.arg("--block-webgl");
}
if config.disable_coop.unwrap_or(false) {
sidecar = sidecar.arg("--disable-coop");
}
if let Some(geoip) = &config.geoip {
match geoip {
serde_json::Value::String(s) => {
sidecar = sidecar.arg("--geoip").arg(s);
}
serde_json::Value::Bool(b) => {
sidecar = sidecar
.arg("--geoip")
.arg(if *b { "auto" } else { "false" });
}
_ => {
sidecar = sidecar.arg("--geoip").arg(geoip.to_string());
}
}
}
if let Some(country) = &config.country {
sidecar = sidecar.arg("--country").arg(country);
}
if let Some(timezone) = &config.timezone {
sidecar = sidecar.arg("--timezone").arg(timezone);
}
if let Some(latitude) = config.latitude {
if let Some(longitude) = config.longitude {
sidecar = sidecar.arg("--latitude").arg(latitude.to_string());
sidecar = sidecar.arg("--longitude").arg(longitude.to_string());
}
}
if let Some(humanize) = config.humanize {
if humanize {
if let Some(duration) = config.humanize_duration {
sidecar = sidecar.arg("--humanize").arg(duration.to_string());
} else {
sidecar = sidecar.arg("--humanize");
}
}
}
if config.headless.unwrap_or(false) {
sidecar = sidecar.arg("--headless");
}
if let Some(locale_list) = &config.locale {
sidecar = sidecar.arg("--locale").arg(locale_list.join(","));
}
if let Some(addons_list) = &config.addons {
sidecar = sidecar.arg("--addons").arg(addons_list.join(","));
}
if let Some(fonts_list) = &config.fonts {
sidecar = sidecar.arg("--fonts").arg(fonts_list.join(","));
}
if config.custom_fonts_only.unwrap_or(false) {
sidecar = sidecar.arg("--custom-fonts-only");
}
if let Some(exclude_addons_list) = &config.exclude_addons {
sidecar = sidecar
.arg("--exclude-addons")
.arg(exclude_addons_list.join(","));
}
// Screen size configuration
if let Some(width) = config.screen_min_width {
sidecar = sidecar.arg("--screen-min-width").arg(width.to_string());
}
if let Some(width) = config.screen_max_width {
sidecar = sidecar.arg("--screen-max-width").arg(width.to_string());
}
if let Some(height) = config.screen_min_height {
sidecar = sidecar.arg("--screen-min-height").arg(height.to_string());
}
if let Some(height) = config.screen_max_height {
sidecar = sidecar.arg("--screen-max-height").arg(height.to_string());
}
if let Some(width) = config.window_width {
sidecar = sidecar.arg("--window-width").arg(width.to_string());
}
if let Some(height) = config.window_height {
sidecar = sidecar.arg("--window-height").arg(height.to_string());
}
// Advanced options
if let Some(ff_version) = config.ff_version {
sidecar = sidecar.arg("--ff-version").arg(ff_version.to_string());
}
if config.main_world_eval.unwrap_or(false) {
sidecar = sidecar.arg("--main-world-eval");
}
if let Some(vendor) = &config.webgl_vendor {
if let Some(renderer) = &config.webgl_renderer {
sidecar = sidecar.arg("--webgl-vendor").arg(vendor);
sidecar = sidecar.arg("--webgl-renderer").arg(renderer);
}
}
if let Some(proxy) = &config.proxy {
sidecar = sidecar.arg("--proxy").arg(proxy);
}
// Cache is enabled by default, only add flag if disabled
if !config.enable_cache.unwrap_or(true) {
sidecar = sidecar.arg("--disable-cache");
}
if let Some(virtual_display) = &config.virtual_display {
sidecar = sidecar.arg("--virtual-display").arg(virtual_display);
}
if config.debug.unwrap_or(false) {
sidecar = sidecar.arg("--debug");
}
if let Some(args) = &config.additional_args {
sidecar = sidecar.arg("--args").arg(args.join(","));
}
if let Some(env_vars) = &config.env_vars {
let env_json = serde_json::to_string(env_vars)
.map_err(|e| format!("Failed to serialize environment variables: {e}"))?;
sidecar = sidecar.arg("--env").arg(env_json);
}
if let Some(firefox_prefs) = &config.firefox_prefs {
let prefs_json = serde_json::to_string(firefox_prefs)
.map_err(|e| format!("Failed to serialize Firefox preferences: {e}"))?;
sidecar = sidecar.arg("--firefox-prefs").arg(prefs_json);
}
// Execute the command
println!("Executing nodecar command...");
let output = sidecar
.output()
.await
.map_err(|e| format!("Failed to execute nodecar command: {e}"))?;
// Check the command status first
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
let stdout_msg = String::from_utf8_lossy(&output.stdout);
return Err(
format!(
"Failed to launch Camoufox: Command failed with status {:?}\nstderr: {}\nstdout: {}",
output.status, error_msg, stdout_msg
)
.into(),
);
}
// Parse the JSON response
let stdout = String::from_utf8_lossy(&output.stdout);
println!("Nodecar stdout: {stdout}");
// Try to parse the JSON response
let result: CamoufoxLaunchResult = serde_json::from_str(&stdout)
.map_err(|e| format!("Failed to parse nodecar response as JSON: {e}\nResponse: {stdout}"))?;
println!("Successfully launched Camoufox with ID: {}", result.id);
Ok(result)
}
/// Stop a Camoufox process by ID
pub async fn stop_camoufox(
&self,
id: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
println!("Stopping Camoufox process with ID: {id}");
// First, we need to find the process to get its executable and profile paths
let processes = self.list_camoufox_processes().await?;
let target_process = processes.iter().find(|p| p.id == id);
if let Some(process) = target_process {
println!(
"Found process to stop: executable={}, profile={}",
process.executablePath, process.profilePath
);
let sidecar = self
.app_handle
.shell()
.sidecar("nodecar")
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
.arg("camoufox")
.arg("stop")
.arg("--executable-path")
.arg(&process.executablePath)
.arg("--profile-path")
.arg(&process.profilePath)
.arg("--id")
.arg(id);
let output = sidecar
.output()
.await
.map_err(|e| format!("Failed to execute nodecar stop command: {e}"))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
let stdout_msg = String::from_utf8_lossy(&output.stdout);
println!("Failed to stop Camoufox process - stderr: {error_msg}, stdout: {stdout_msg}");
return Err(format!("Failed to stop Camoufox process: {error_msg}").into());
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!("Stop command result: {stdout}");
// Parse the JSON response which contains a "success" field
let response: serde_json::Value = serde_json::from_str(&stdout)
.map_err(|e| format!("Failed to parse stop response as JSON: {e}\nResponse: {stdout}"))?;
let success = response
.get("success")
.and_then(|v| v.as_bool())
.ok_or_else(|| {
format!("Invalid response format - missing or invalid 'success' field: {stdout}")
})?;
if success {
println!("Successfully stopped Camoufox process: {id}");
} else {
println!("Failed to stop Camoufox process: {id} (process may not exist)");
}
Ok(success)
} else {
println!("Camoufox process with ID {id} not found in running processes");
// If we can't find the process, it might already be stopped
Ok(false)
}
}
/// List all Camoufox processes
pub async fn list_camoufox_processes(
&self,
) -> Result<Vec<CamoufoxLaunchResult>, Box<dyn std::error::Error + Send + Sync>> {
println!("Listing Camoufox processes...");
// For the list command, we need to provide dummy executable-path and profile-path
// even though they're not used by the list action
let sidecar = self
.app_handle
.shell()
.sidecar("nodecar")
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
.arg("camoufox")
.arg("list")
.arg("--executable-path")
.arg("/dummy/path") // Dummy path since list doesn't use it
.arg("--profile-path")
.arg("/dummy/profile"); // Dummy path since list doesn't use it
let output = sidecar
.output()
.await
.map_err(|e| format!("Failed to execute nodecar list command: {e}"))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to list Camoufox processes: {error_msg}").into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
println!("List command result: {stdout}");
// Parse the response as an array of process info
let processes: Vec<serde_json::Value> =
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse list response: {e}"))?;
// Convert to CamoufoxLaunchResult format
let mut results = Vec::new();
for process in processes {
// Handle both camelCase and snake_case formats from nodecar
let id = process.get("id").and_then(|v| v.as_str());
// Try both formats for executable path
let executable_path = process
.get("executable_path")
.and_then(|v| v.as_str())
.or_else(|| process.get("executablePath").and_then(|v| v.as_str()));
// Try both formats for profile path
let profile_path = process
.get("profile_path")
.and_then(|v| v.as_str())
.or_else(|| process.get("profilePath").and_then(|v| v.as_str()));
if let (Some(id), Some(executable_path), Some(profile_path)) =
(id, executable_path, profile_path)
{
let pid = process
.get("pid")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let url = process
.get("url")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
results.push(CamoufoxLaunchResult {
id: id.to_string(),
pid,
executablePath: executable_path.to_string(),
profilePath: profile_path.to_string(),
url,
});
} else {
println!("Skipping malformed process entry: {process:?}");
}
}
println!("Parsed {} valid Camoufox processes", results.len());
Ok(results)
}
/// Find Camoufox process by profile path (for integration with browser_runner)
pub async fn find_camoufox_by_profile(
&self,
profile_path: &str,
) -> Result<Option<CamoufoxLaunchResult>, Box<dyn std::error::Error + Send + Sync>> {
println!("Looking for Camoufox process with profile path: {profile_path}");
let processes = self.list_camoufox_processes().await?;
println!("Found {} running Camoufox processes", processes.len());
for process in &processes {
println!(
"Checking process with profile path: {}",
process.profilePath
);
}
// Convert both 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 process in &processes {
println!(
"Comparing target path: {} with process path: {}",
target_path.display(),
process.profilePath
);
// Try multiple comparison methods
let process_path = std::path::Path::new(&process.profilePath)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(&process.profilePath).to_path_buf());
// Method 1: Canonical path comparison
if process_path == target_path {
println!("Found match using canonical path comparison");
return Ok(Some(process.clone()));
}
// Method 2: Direct string comparison
if process.profilePath == profile_path {
println!("Found match using direct string comparison");
return Ok(Some(process.clone()));
}
// Method 3: Compare as strings after canonicalization
if process_path.to_string_lossy() == target_path.to_string_lossy() {
println!("Found match using canonical string comparison");
return Ok(Some(process.clone()));
}
// Method 4: Compare file names if full paths don't match
if let (Some(process_file), Some(target_file)) =
(process_path.file_name(), target_path.file_name())
{
if process_file == target_file {
// If the parent directories also match, it's likely the same profile
if let (Some(process_parent), Some(target_parent)) =
(process_path.parent(), target_path.parent())
{
if process_parent == target_parent {
println!("Found match using parent directory and file name comparison");
return Ok(Some(process.clone()));
}
}
}
}
// Method 5: Check if either path contains the other (for symlinks or different representations)
let process_path_str = process_path.to_string_lossy();
let target_path_str = target_path.to_string_lossy();
if process_path_str.contains(target_path_str.as_ref())
|| target_path_str.contains(process_path_str.as_ref())
{
println!("Found match using path containment check");
return Ok(Some(process.clone()));
}
}
println!("No matching Camoufox process found for profile path: {profile_path}");
Ok(None)
}
}
pub async fn launch_camoufox_profile(
app_handle: AppHandle,
profile: BrowserProfile,
config: CamoufoxConfig,
url: Option<String>,
) -> Result<CamoufoxLaunchResult, String> {
let launcher = CamoufoxLauncher::new(app_handle);
// Get the executable path for Camoufox
let browser_runner = crate::browser_runner::BrowserRunner::new();
let binaries_dir = browser_runner.get_binaries_dir();
let browser_dir = binaries_dir.join("camoufox").join(&profile.version);
// Get executable path
let browser = crate::browser::create_browser(crate::browser::BrowserType::Camoufox);
let executable_path = browser
.get_executable_path(&browser_dir)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
// Get profile path
let profiles_dir = browser_runner.get_profiles_dir();
let profile_path = profile.get_profile_data_path(&profiles_dir);
launcher
.launch_camoufox(
&executable_path.to_string_lossy(),
&profile_path.to_string_lossy(),
&config,
url.as_deref(),
)
.await
.map_err(|e| format!("Failed to launch Camoufox: {e}"))
}
+20 -4
View File
@@ -158,7 +158,7 @@ mod windows {
app_key
.set_value(
"ApplicationDescription",
&"Donut Browser - Simple Yet Powerful Browser Orchestrator",
&"Donut Browser - Simple Yet Powerful Anti-Detect Browser",
)
.map_err(|e| format!("Failed to set ApplicationDescription: {}", e))?;
@@ -174,7 +174,7 @@ mod windows {
capabilities
.set_value(
"ApplicationDescription",
&"Donut Browser - Simple Yet Powerful Browser Orchestrator",
&"Donut Browser - Simple Yet Powerful Anti-Detect Browser",
)
.map_err(|e| format!("Failed to set Capabilities description: {}", e))?;
@@ -605,9 +605,25 @@ pub async fn smart_open_url(
}
}
// For Mullvad browser: skip if running (can't open URLs in running Mullvad)
// For Mullvad browser: Check if any other Mullvad browser is running
if profile.browser == "mullvad-browser" {
continue;
let mut other_mullvad_running = false;
for p in &profiles {
if p.browser == "mullvad-browser"
&& p.name != profile.name
&& runner
.check_browser_status(app_handle.clone(), p)
.await
.unwrap_or(false)
{
other_mullvad_running = true;
break;
}
}
if other_mullvad_running {
continue; // Skip this one, can't have multiple Mullvad instances
}
}
// Try to open the URL with this running profile
+53
View File
@@ -147,6 +147,30 @@ impl Downloader {
Ok(asset_url)
}
BrowserType::Camoufox => {
// For Camoufox, verify the asset exists and find the correct download URL
let releases = self
.api_client
.fetch_camoufox_releases_with_caching(true)
.await?;
let release = releases
.iter()
.find(|r| r.tag_name == version)
.ok_or(format!("Camoufox version {version} not found"))?;
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_camoufox_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No compatible asset found for Camoufox version {version} on {os}/{arch}"
))?;
Ok(asset_url)
}
_ => {
// For other browsers, use the provided URL
Ok(download_info.url.clone())
@@ -321,6 +345,35 @@ impl Downloader {
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Camoufox asset for the current platform and architecture
fn find_camoufox_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Camoufox asset naming pattern: camoufox-{version}-{release}-{os}.{arch}.zip
let (os_name, arch_name) = match (os, arch) {
("windows", "x64") => ("win", "x86_64"),
("windows", "arm64") => ("win", "arm64"),
("linux", "x64") => ("lin", "x86_64"),
("linux", "arm64") => ("lin", "arm64"),
("macos", "x64") => ("mac", "x86_64"),
("macos", "arm64") => ("mac", "arm64"),
_ => return None,
};
// Look for assets matching the pattern
let asset = assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.starts_with("camoufox-")
&& name.contains(&format!("-{os_name}.{arch_name}.zip"))
&& name.ends_with(".zip")
});
asset.map(|a| a.browser_download_url.clone())
}
pub async fn download_browser<R: tauri::Runtime>(
&self,
app_handle: &tauri::AppHandle<R>,
+171
View File
@@ -0,0 +1,171 @@
use crate::browser::GithubRelease;
use directories::BaseDirs;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::Emitter;
use tokio::fs;
use tokio::io::AsyncWriteExt;
const MMDB_REPO: &str = "P3TERX/GeoLite.mmdb";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeoIPDownloadProgress {
pub stage: String, // "downloading", "extracting", "completed"
pub percentage: f64,
pub message: String,
}
pub struct GeoIPDownloader {
client: Client,
}
impl GeoIPDownloader {
pub fn new() -> Self {
Self {
client: Client::new(),
}
}
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let base_dirs = BaseDirs::new().ok_or("Failed to determine base directories")?;
#[cfg(target_os = "windows")]
let cache_dir = base_dirs
.data_local_dir()
.join("camoufox")
.join("camoufox")
.join("Cache");
#[cfg(target_os = "macos")]
let cache_dir = base_dirs.cache_dir().join("camoufox");
#[cfg(target_os = "linux")]
let cache_dir = base_dirs.cache_dir().join("camoufox");
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
let cache_dir = base_dirs.cache_dir().join("camoufox");
Ok(cache_dir)
}
fn get_mmdb_file_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
Ok(Self::get_cache_dir()?.join("GeoLite2-City.mmdb"))
}
pub fn is_geoip_database_available() -> bool {
if let Ok(mmdb_path) = Self::get_mmdb_file_path() {
mmdb_path.exists()
} else {
false
}
}
fn find_city_mmdb_asset(&self, release: &GithubRelease) -> Option<String> {
for asset in &release.assets {
if asset.name.ends_with("-City.mmdb") {
return Some(asset.browser_download_url.clone());
}
}
None
}
pub async fn download_geoip_database(
&self,
app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Emit initial progress
let _ = app_handle.emit(
"geoip-download-progress",
GeoIPDownloadProgress {
stage: "downloading".to_string(),
percentage: 0.0,
message: "Starting GeoIP database download".to_string(),
},
);
// Fetch latest release from GitHub
let releases = self.fetch_geoip_releases().await?;
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
let download_url = self
.find_city_mmdb_asset(latest_release)
.ok_or("No compatible GeoIP database asset found")?;
// Create cache directory
let cache_dir = Self::get_cache_dir()?;
fs::create_dir_all(&cache_dir).await?;
let mmdb_path = Self::get_mmdb_file_path()?;
// Download the file
let response = self.client.get(&download_url).send().await?;
if !response.status().is_success() {
return Err(
format!(
"Failed to download GeoIP database: HTTP {}",
response.status()
)
.into(),
);
}
let total_size = response.content_length().unwrap_or(0);
let mut downloaded = 0;
let mut file = fs::File::create(&mmdb_path).await?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
downloaded += chunk.len() as u64;
file.write_all(&chunk).await?;
if total_size > 0 {
let percentage = (downloaded as f64 / total_size as f64) * 100.0;
let _ = app_handle.emit(
"geoip-download-progress",
GeoIPDownloadProgress {
stage: "downloading".to_string(),
percentage,
message: format!("Downloaded {downloaded} / {total_size} bytes"),
},
);
}
}
file.flush().await?;
// Emit completion
let _ = app_handle.emit(
"geoip-download-progress",
GeoIPDownloadProgress {
stage: "completed".to_string(),
percentage: 100.0,
message: "GeoIP database download completed".to_string(),
},
);
Ok(())
}
async fn fetch_geoip_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("https://api.github.com/repos/{MMDB_REPO}/releases");
let response = self
.client
.get(&url)
.header("User-Agent", "Mozilla/5.0 (compatible; donutbrowser)")
.send()
.await?;
if !response.status().is_success() {
return Err(format!("Failed to fetch releases: HTTP {}", response.status()).into());
}
let releases: Vec<GithubRelease> = response.json().await?;
Ok(releases)
}
}
+20
View File
@@ -13,13 +13,17 @@ mod auto_updater;
mod browser;
mod browser_runner;
mod browser_version_service;
mod camoufox;
mod default_browser;
mod download;
mod downloaded_browsers;
mod extraction;
mod geoip_downloader;
mod profile_importer;
mod proxy_manager;
mod settings_manager;
mod system_utils;
mod theme_detector;
mod version_updater;
@@ -60,6 +64,8 @@ use profile_importer::{detect_existing_profiles, import_browser_profile};
use theme_detector::get_system_theme;
use system_utils::{get_system_locale, get_system_timezone};
// Trait to extend WebviewWindow with transparent titlebar functionality
pub trait WindowExt {
#[cfg(target_os = "macos")]
@@ -207,6 +213,17 @@ async fn delete_stored_proxy(proxy_id: String) -> Result<(), String> {
.map_err(|e| format!("Failed to delete stored proxy: {e}"))
}
#[tauri::command]
async fn update_camoufox_config(
profile_name: String,
config: crate::camoufox::CamoufoxConfig,
) -> Result<(), String> {
let browser_runner = browser_runner::BrowserRunner::new();
browser_runner
.update_camoufox_config(&profile_name, config)
.map_err(|e| format!("Failed to update Camoufox config: {e}"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = env::args().collect();
@@ -433,6 +450,9 @@ pub fn run() {
get_stored_proxies,
update_stored_proxy,
delete_stored_proxy,
update_camoufox_config,
get_system_locale,
get_system_timezone,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+8 -12
View File
@@ -689,6 +689,7 @@ impl ProfileImporter {
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
};
// Save the profile metadata
@@ -708,7 +709,7 @@ impl ProfileImporter {
&self,
browser_type: &str,
) -> Result<String, Box<dyn std::error::Error>> {
// Try to get a downloaded version first, fallback to a reasonable default
// Check if any version of the browser is downloaded
let registry =
crate::downloaded_browsers::DownloadedBrowsersRegistry::load().unwrap_or_default();
let downloaded_versions = registry.get_downloaded_versions(browser_type);
@@ -717,17 +718,12 @@ impl ProfileImporter {
return Ok(version.clone());
}
// If no downloaded versions, return a sensible default
match browser_type {
"firefox" => Ok("latest".to_string()),
"firefox-developer" => Ok("latest".to_string()),
"chromium" => Ok("latest".to_string()),
"brave" => Ok("latest".to_string()),
"zen" => Ok("latest".to_string()),
"mullvad-browser" => Ok("13.5.16".to_string()), // Mullvad Browser common version
"tor-browser" => Ok("latest".to_string()),
_ => Ok("latest".to_string()),
}
// If no downloaded versions found, return an error
Err(format!(
"No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.",
browser_type,
self.get_browser_display_name(browser_type)
).into())
}
/// Recursively copy directory contents
+1 -5
View File
@@ -173,11 +173,6 @@ impl ProxyManager {
}
// Get a stored proxy by ID
#[allow(dead_code)]
pub fn get_stored_proxy(&self, proxy_id: &str) -> Option<StoredProxy> {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.get(proxy_id).cloned()
}
// Update a stored proxy
pub fn update_stored_proxy(
@@ -418,6 +413,7 @@ impl ProxyManager {
}
// Get proxy settings for a browser process ID
#[allow(dead_code)]
pub fn get_proxy_settings(&self, browser_pid: u32) -> Option<ProxySettings> {
let proxies = self.active_proxies.lock().unwrap();
proxies.get(&browser_pid).map(|proxy| ProxySettings {
+331
View File
@@ -0,0 +1,331 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SystemLocale {
pub locale: String,
pub language: String,
pub country: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SystemTimezone {
pub timezone: String,
pub offset: String,
}
pub struct SystemUtils;
impl SystemUtils {
pub fn new() -> Self {
Self
}
/// Detect the system's locale settings
pub fn detect_system_locale(&self) -> SystemLocale {
#[cfg(target_os = "macos")]
return macos::detect_system_locale();
#[cfg(target_os = "linux")]
return linux::detect_system_locale();
#[cfg(target_os = "windows")]
return windows::detect_system_locale();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
return SystemLocale {
locale: "en-US".to_string(),
language: "en".to_string(),
country: "US".to_string(),
};
}
/// Detect the system's timezone settings
pub fn detect_system_timezone(&self) -> SystemTimezone {
#[cfg(target_os = "macos")]
return macos::detect_system_timezone();
#[cfg(target_os = "linux")]
return linux::detect_system_timezone();
#[cfg(target_os = "windows")]
return windows::detect_system_timezone();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
return SystemTimezone {
timezone: "UTC".to_string(),
offset: "+00:00".to_string(),
};
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::*;
pub fn detect_system_locale() -> SystemLocale {
// Try to get the system locale from macOS
if let Ok(output) = Command::new("defaults")
.args(["read", "-g", "AppleLocale"])
.output()
{
if output.status.success() {
let locale_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
return parse_locale(&locale_str);
}
}
// Fallback to environment variables
detect_locale_from_env()
}
pub fn detect_system_timezone() -> SystemTimezone {
// Try to get timezone from macOS system
if let Ok(output) = Command::new("date").arg("+%Z").output() {
if output.status.success() {
let tz_abbr = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Get the full timezone name
if let Ok(tz_output) = Command::new("systemsetup").args(["-gettimezone"]).output() {
if tz_output.status.success() {
let tz_full = String::from_utf8_lossy(&tz_output.stdout);
if let Some(tz_name) = tz_full.strip_prefix("Time Zone: ") {
let tz_clean = tz_name.trim().to_string();
if !tz_clean.is_empty() {
return SystemTimezone {
timezone: tz_clean,
offset: tz_abbr,
};
}
}
}
}
}
}
// Fallback to reading /etc/localtime link
detect_timezone_from_files()
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
pub fn detect_system_locale() -> SystemLocale {
// Try to get locale from locale command
if let Ok(output) = Command::new("locale").output() {
if output.status.success() {
let output_str = String::from_utf8_lossy(&output.stdout);
for line in output_str.lines() {
if line.starts_with("LANG=") {
let locale_value = line.strip_prefix("LANG=").unwrap_or("");
let locale_clean = locale_value.trim_matches('"');
return parse_locale(locale_clean);
}
}
}
}
// Fallback to environment variables
detect_locale_from_env()
}
pub fn detect_system_timezone() -> SystemTimezone {
// Try to read /etc/timezone first (Debian/Ubuntu)
if let Ok(tz_content) = std::fs::read_to_string("/etc/timezone") {
let tz_name = tz_content.trim().to_string();
if !tz_name.is_empty() {
return SystemTimezone {
timezone: tz_name,
offset: get_timezone_offset(),
};
}
}
// Try timedatectl (systemd systems)
if let Ok(output) = Command::new("timedatectl")
.args(["show", "--property=Timezone", "--value"])
.output()
{
if output.status.success() {
let tz_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !tz_name.is_empty() {
return SystemTimezone {
timezone: tz_name,
offset: get_timezone_offset(),
};
}
}
}
// Fallback to reading /etc/localtime symlink
detect_timezone_from_files()
}
fn get_timezone_offset() -> String {
if let Ok(output) = Command::new("date").arg("+%z").output() {
if output.status.success() {
return String::from_utf8_lossy(&output.stdout).trim().to_string();
}
}
"+00:00".to_string()
}
}
#[cfg(target_os = "windows")]
mod windows {
use super::*;
pub fn detect_system_locale() -> SystemLocale {
// Try to get locale from Windows registry/powershell
if let Ok(output) = Command::new("powershell")
.args([
"-Command",
"Get-Culture | Select-Object -ExpandProperty Name",
])
.output()
{
if output.status.success() {
let locale_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
return parse_locale(&locale_str);
}
}
// Fallback to environment variables
detect_locale_from_env()
}
pub fn detect_system_timezone() -> SystemTimezone {
// Try to get timezone from Windows
if let Ok(output) = Command::new("powershell")
.args([
"-Command",
"Get-TimeZone | Select-Object -ExpandProperty Id",
])
.output()
{
if output.status.success() {
let tz_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !tz_id.is_empty() {
return SystemTimezone {
timezone: tz_id,
offset: get_windows_timezone_offset(),
};
}
}
}
// Fallback
SystemTimezone {
timezone: "UTC".to_string(),
offset: "+00:00".to_string(),
}
}
fn get_windows_timezone_offset() -> String {
if let Ok(output) = Command::new("powershell")
.args([
"-Command",
"Get-TimeZone | Select-Object -ExpandProperty BaseUtcOffset",
])
.output()
{
if output.status.success() {
let offset_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Convert Windows offset format to standard format
if let Some(colon_pos) = offset_str.find(':') {
let hours = &offset_str[..colon_pos];
let minutes = &offset_str[colon_pos + 1..];
if let (Ok(h), Ok(m)) = (hours.parse::<i32>(), minutes.parse::<i32>()) {
return format!("{:+03}:{:02}", h, m);
}
}
}
}
"+00:00".to_string()
}
}
// Helper functions used across platforms
fn parse_locale(locale_str: &str) -> SystemLocale {
// Remove encoding suffix if present (e.g., "en_US.UTF-8" -> "en_US")
let locale_base = locale_str.split('.').next().unwrap_or(locale_str);
// Split language and country (e.g., "en_US" -> ["en", "US"])
let parts: Vec<&str> = locale_base.split(&['_', '-']).collect();
let language = parts.first().unwrap_or(&"en").to_string();
let country = parts.get(1).unwrap_or(&"US").to_string();
// Convert to standard format (e.g., "en-US")
let standard_locale = if parts.len() >= 2 {
format!("{}-{}", language, country.to_uppercase())
} else {
format!("{language}-US")
};
SystemLocale {
locale: standard_locale,
language,
country: country.to_uppercase(),
}
}
fn detect_locale_from_env() -> SystemLocale {
// Check environment variables in order of preference
let env_vars = ["LANG", "LC_ALL", "LC_CTYPE", "LANGUAGE"];
for var in &env_vars {
if let Ok(value) = std::env::var(var) {
if !value.is_empty() {
return parse_locale(&value);
}
}
}
// Default fallback
SystemLocale {
locale: "en-US".to_string(),
language: "en".to_string(),
country: "US".to_string(),
}
}
fn detect_timezone_from_files() -> SystemTimezone {
// Try to read timezone from /etc/localtime symlink
if let Ok(link_target) = std::fs::read_link("/etc/localtime") {
if let Some(tz_path) = link_target.to_str() {
// Extract timezone name from path like /usr/share/zoneinfo/America/New_York
if let Some(zoneinfo_pos) = tz_path.find("zoneinfo/") {
let tz_name = &tz_path[zoneinfo_pos + 9..];
if !tz_name.is_empty() {
return SystemTimezone {
timezone: tz_name.to_string(),
offset: "+00:00".to_string(), // Could be improved with actual offset calculation
};
}
}
}
}
// Default fallback
SystemTimezone {
timezone: "UTC".to_string(),
offset: "+00:00".to_string(),
}
}
/// Tauri command to get system locale
#[tauri::command]
pub async fn get_system_locale() -> Result<SystemLocale, String> {
let utils = SystemUtils::new();
Ok(utils.detect_system_locale())
}
/// Tauri command to get system timezone
#[tauri::command]
pub async fn get_system_timezone() -> Result<SystemTimezone, String> {
let utils = SystemUtils::new();
Ok(utils.detect_system_timezone())
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.6.0",
"version": "0.7.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
+44 -2
View File
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { ChangeVersionDialog } from "@/components/change-version-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { ImportProfileDialog } from "@/components/import-profile-dialog";
@@ -36,7 +37,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { showErrorToast } from "@/lib/toast-utils";
import { sleep } from "@/lib/utils";
import type { BrowserProfile } from "@/types";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
type BrowserTypeString =
| "mullvad-browser"
@@ -45,7 +46,8 @@ type BrowserTypeString =
| "chromium"
| "brave"
| "zen"
| "tor-browser";
| "tor-browser"
| "camoufox";
interface PendingUrl {
id: string;
@@ -62,11 +64,15 @@ export default function Home() {
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
const [proxyManagementDialogOpen, setProxyManagementDialogOpen] =
useState(false);
const [camoufoxConfigDialogOpen, setCamoufoxConfigDialogOpen] =
useState(false);
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
const [currentProfileForProxy, setCurrentProfileForProxy] =
useState<BrowserProfile | null>(null);
const [currentProfileForVersionChange, setCurrentProfileForVersionChange] =
useState<BrowserProfile | null>(null);
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
useState<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
const [currentPermissionType, setCurrentPermissionType] =
@@ -326,6 +332,30 @@ export default function Home() {
setChangeVersionDialogOpen(true);
}, []);
const handleConfigureCamoufox = useCallback((profile: BrowserProfile) => {
setCurrentProfileForCamoufoxConfig(profile);
setCamoufoxConfigDialogOpen(true);
}, []);
const handleSaveCamoufoxConfig = useCallback(
async (profile: BrowserProfile, config: CamoufoxConfig) => {
setError(null);
try {
await invoke("update_camoufox_config", {
profileName: profile.name,
config,
});
await loadProfiles();
setCamoufoxConfigDialogOpen(false);
} catch (err: unknown) {
console.error("Failed to update camoufox config:", err);
setError(`Failed to update camoufox config: ${JSON.stringify(err)}`);
throw err;
}
},
[loadProfiles],
);
const handleSaveProxy = useCallback(
async (proxyId: string | null) => {
setProxyDialogOpen(false);
@@ -356,6 +386,7 @@ export default function Home() {
version: string;
releaseType: string;
proxyId?: string;
camoufoxConfig?: CamoufoxConfig;
}) => {
setError(null);
@@ -368,6 +399,7 @@ export default function Home() {
version: profileData.version,
releaseType: profileData.releaseType,
proxyId: profileData.proxyId,
camoufoxConfig: profileData.camoufoxConfig,
},
);
@@ -658,6 +690,7 @@ export default function Home() {
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onChangeVersion={openChangeVersionDialog}
onConfigureCamoufox={handleConfigureCamoufox}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onReloadProxyData={
@@ -739,6 +772,15 @@ export default function Home() {
permissionType={currentPermissionType}
onPermissionGranted={checkNextPermission}
/>
<CamoufoxConfigDialog
isOpen={camoufoxConfigDialogOpen}
onClose={() => {
setCamoufoxConfigDialogOpen(false);
}}
profile={currentProfileForCamoufoxConfig}
onSave={handleSaveCamoufoxConfig}
/>
</div>
);
}
+43 -26
View File
@@ -65,11 +65,18 @@ export function AppUpdateToast({
await onUpdate(updateInfo);
};
const showProgress =
const showDownloadProgress =
isUpdating &&
updateProgress?.stage === "downloading" &&
updateProgress.percentage !== undefined;
const showOtherStageProgress =
isUpdating &&
updateProgress &&
(updateProgress.stage === "extracting" ||
updateProgress.stage === "installing" ||
updateProgress.stage === "completed");
return (
<div className="flex items-start p-4 w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-lg dark:bg-gray-800 dark:border-gray-700">
<div className="mr-3 mt-0.5">
@@ -117,7 +124,7 @@ export function AppUpdateToast({
</div>
{/* Download progress */}
{showProgress && updateProgress && (
{showDownloadProgress && updateProgress && (
<div className="mt-2 space-y-1">
<div className="flex justify-between items-center">
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
@@ -135,31 +142,41 @@ export function AppUpdateToast({
</div>
)}
{/* Other stage progress (without percentage) */}
{isUpdating &&
updateProgress &&
updateProgress.stage !== "downloading" && (
<div className="mt-2">
<p className="text-xs text-muted-foreground">
{updateProgress.message}
</p>
{updateProgress.stage === "extracting" && (
<p className="mt-1 text-xs text-muted-foreground">
Preparing update files...
</p>
)}
{updateProgress.stage === "installing" && (
<p className="mt-1 text-xs text-muted-foreground">
Installing new version...
</p>
)}
{updateProgress.stage === "completed" && (
<p className="mt-1 text-xs text-green-600 dark:text-green-400">
Update completed! Restarting application...
</p>
)}
{/* Other stage progress (with visual indicators) */}
{showOtherStageProgress && (
<div className="mt-2 space-y-2">
<p className="text-xs text-muted-foreground">
{updateProgress.message}
</p>
{/* Progress indicator for non-downloading stages */}
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all duration-500 ${
updateProgress.stage === "completed"
? "bg-green-500 w-full"
: "bg-blue-500 w-full animate-pulse"
}`}
/>
</div>
)}
{updateProgress.stage === "extracting" && (
<p className="text-xs text-muted-foreground">
Preparing update files...
</p>
)}
{updateProgress.stage === "installing" && (
<p className="text-xs text-muted-foreground">
Installing new version...
</p>
)}
{updateProgress.stage === "completed" && (
<p className="text-xs text-green-600 dark:text-green-400">
Update completed! Restarting application...
</p>
)}
</div>
)}
{!isUpdating && (
<div className="flex gap-2 items-center mt-3">
+502
View File
@@ -0,0 +1,502 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
interface CamoufoxConfigDialogProps {
isOpen: boolean;
onClose: () => void;
profile: BrowserProfile | null;
onSave: (profile: BrowserProfile, config: CamoufoxConfig) => Promise<void>;
}
const osOptions = [
{ value: "windows", label: "Windows" },
{ value: "macos", label: "macOS" },
{ value: "linux", label: "Linux" },
];
const timezoneOptions = [
{ value: "America/New_York", label: "America/New_York" },
{ value: "America/Los_Angeles", label: "America/Los_Angeles" },
{ value: "Europe/London", label: "Europe/London" },
{ value: "Europe/Paris", label: "Europe/Paris" },
{ value: "Asia/Tokyo", label: "Asia/Tokyo" },
{ value: "Asia/Shanghai", label: "Asia/Shanghai" },
{ value: "Australia/Sydney", label: "Australia/Sydney" },
];
const localeOptions = [
{ value: "en-US", label: "English (US)" },
{ value: "en-GB", label: "English (UK)" },
{ value: "fr-FR", label: "French" },
{ value: "de-DE", label: "German" },
{ value: "es-ES", label: "Spanish" },
{ value: "it-IT", label: "Italian" },
{ value: "ja-JP", label: "Japanese" },
{ value: "zh-CN", label: "Chinese (Simplified)" },
];
const getCurrentOS = () => {
if (typeof window !== "undefined") {
const userAgent = window.navigator.userAgent;
if (userAgent.includes("Win")) return "windows";
if (userAgent.includes("Mac")) return "macos";
if (userAgent.includes("Linux")) return "linux";
}
return "unknown";
};
export function CamoufoxConfigDialog({
isOpen,
onClose,
profile,
onSave,
}: CamoufoxConfigDialogProps) {
const [config, setConfig] = useState<CamoufoxConfig>({
enable_cache: true,
os: [getCurrentOS()],
});
const [isSaving, setIsSaving] = useState(false);
// Initialize config when profile changes
useEffect(() => {
if (profile && profile.browser === "camoufox") {
setConfig(
profile.camoufox_config || {
enable_cache: true,
os: [getCurrentOS()],
},
);
}
}, [profile]);
const updateConfig = (key: keyof CamoufoxConfig, value: unknown) => {
setConfig((prev) => ({ ...prev, [key]: value }));
};
const handleSave = async () => {
if (!profile) return;
setIsSaving(true);
try {
await onSave(profile, config);
onClose();
} catch (error) {
console.error("Failed to save camoufox config:", error);
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
// Reset config to original when closing without saving
if (profile && profile.browser === "camoufox") {
setConfig(
profile.camoufox_config || {
enable_cache: true,
os: [getCurrentOS()],
},
);
}
onClose();
};
if (!profile || profile.browser !== "camoufox") {
return null;
}
// Get the selected OS for warning
const selectedOS = config.os?.[0];
const currentOS = getCurrentOS();
const showOSWarning =
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>
Configure Camoufox Settings - {profile.name}
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 pr-6 h-[350px]">
<div className="py-4 space-y-6">
{/* Operating System */}
<div className="space-y-3">
<Label>Operating System Fingerprint</Label>
<Select
value={selectedOS || ""}
onValueChange={(value: string) => updateConfig("os", [value])}
>
<SelectTrigger>
<SelectValue placeholder="Select OS" />
</SelectTrigger>
<SelectContent>
{osOptions.map((os) => (
<SelectItem key={os.value} value={os.value}>
{os.label}
</SelectItem>
))}
</SelectContent>
</Select>
{showOSWarning && (
<div className="p-3 bg-amber-50 rounded-md border border-amber-200">
<p className="text-sm text-amber-800">
Warning: Spoofing OS features is detectable by advanced
anti-bot systems. Some platform-specific APIs and behaviors
cannot be fully replicated.
</p>
</div>
)}
</div>
{/* Blocking Options */}
<div className="space-y-3">
<Label>Privacy & Blocking</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="block-images"
checked={config.block_images || false}
onCheckedChange={(checked) =>
updateConfig("block_images", checked)
}
/>
<Label htmlFor="block-images">Block Images</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="block-webrtc"
checked={config.block_webrtc || false}
onCheckedChange={(checked) =>
updateConfig("block_webrtc", checked)
}
/>
<Label htmlFor="block-webrtc">Block WebRTC</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="block-webgl"
checked={config.block_webgl || false}
onCheckedChange={(checked) =>
updateConfig("block_webgl", checked)
}
/>
<Label htmlFor="block-webgl">Block WebGL</Label>
</div>
</div>
</div>
{/* Geolocation */}
<div className="space-y-3">
<Label>Geolocation</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<Input
id="country"
value={config.country || ""}
onChange={(e) =>
updateConfig("country", e.target.value || undefined)
}
placeholder="e.g., US, GB, DE"
/>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<Select
value={config.timezone || "auto"}
onValueChange={(value) =>
updateConfig(
"timezone",
value === "auto" ? undefined : value,
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
{timezoneOptions.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="latitude">Latitude</Label>
<Input
id="latitude"
type="number"
step="any"
value={config.latitude || ""}
onChange={(e) =>
updateConfig(
"latitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., 40.7128"
/>
</div>
<div className="space-y-2">
<Label htmlFor="longitude">Longitude</Label>
<Input
id="longitude"
type="number"
step="any"
value={config.longitude || ""}
onChange={(e) =>
updateConfig(
"longitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., -74.0060"
/>
</div>
</div>
</div>
{/* Localization */}
<div className="space-y-3">
<Label>Locale</Label>
<Select
value={config.locale?.[0] || ""}
onValueChange={(value) =>
updateConfig("locale", value ? [value] : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Select locale" />
</SelectTrigger>
<SelectContent>
{localeOptions.map((locale) => (
<SelectItem key={locale.value} value={locale.value}>
{locale.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Screen Resolution */}
<div className="space-y-3">
<Label>Screen Resolution</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-min-width">Min Width</Label>
<Input
id="screen-min-width"
type="number"
value={config.screen_min_width || ""}
onChange={(e) =>
updateConfig(
"screen_min_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1024"
/>
</div>
<div className="space-y-2">
<Label htmlFor="screen-max-width">Max Width</Label>
<Input
id="screen-max-width"
type="number"
value={config.screen_max_width || ""}
onChange={(e) =>
updateConfig(
"screen_max_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1920"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-min-height">Min Height</Label>
<Input
id="screen-min-height"
type="number"
value={config.screen_min_height || ""}
onChange={(e) =>
updateConfig(
"screen_min_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 768"
/>
</div>
<div className="space-y-2">
<Label htmlFor="screen-max-height">Max Height</Label>
<Input
id="screen-max-height"
type="number"
value={config.screen_max_height || ""}
onChange={(e) =>
updateConfig(
"screen_max_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1080"
/>
</div>
</div>
</div>
{/* Window Size */}
<div className="space-y-3">
<Label>Window Size</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="window-width">Width</Label>
<Input
id="window-width"
type="number"
value={config.window_width || ""}
onChange={(e) =>
updateConfig(
"window_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1366"
/>
</div>
<div className="space-y-2">
<Label htmlFor="window-height">Height</Label>
<Input
id="window-height"
type="number"
value={config.window_height || ""}
onChange={(e) =>
updateConfig(
"window_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 768"
/>
</div>
</div>
</div>
{/* Advanced Options */}
<div className="space-y-3">
<Label>Advanced Options</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="enable-cache"
checked={config.enable_cache || false}
onCheckedChange={(checked) =>
updateConfig("enable_cache", checked)
}
/>
<Label htmlFor="enable-cache">Enable Browser Cache</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="main-world-eval"
checked={config.main_world_eval || false}
onCheckedChange={(checked) =>
updateConfig("main_world_eval", checked)
}
/>
<Label htmlFor="main-world-eval">
Enable Main World Evaluation
</Label>
</div>
</div>
</div>
{/* WebGL Settings */}
<div className="space-y-3">
<Label>WebGL Settings</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="webgl-vendor">WebGL Vendor</Label>
<Input
id="webgl-vendor"
value={config.webgl_vendor || ""}
onChange={(e) =>
updateConfig("webgl_vendor", e.target.value || undefined)
}
placeholder="e.g., Intel Inc."
/>
</div>
<div className="space-y-2">
<Label htmlFor="webgl-renderer">WebGL Renderer</Label>
<Input
id="webgl-renderer"
value={config.webgl_renderer || ""}
onChange={(e) =>
updateConfig(
"webgl_renderer",
e.target.value || undefined,
)
}
placeholder="e.g., Intel Iris OpenGL Engine"
/>
</div>
</div>
</div>
{/* Debug Options */}
<div className="space-y-3">
<Label>Debug Options</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="debug"
checked={config.debug || false}
onCheckedChange={(checked) => updateConfig("debug", checked)}
/>
<Label htmlFor="debug">Enable Debug Mode</Label>
</div>
</div>
</div>
</ScrollArea>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Configuration"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+345 -416
View File
@@ -2,12 +2,10 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { FiPlus } from "react-icons/fi";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { ReleaseTypeSelector } from "@/components/release-type-selector";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
@@ -17,6 +15,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -24,16 +23,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import type { BrowserProfile, BrowserReleaseTypes, StoredProxy } from "@/types";
import { Alert, AlertDescription } from "./ui/alert";
import { getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
type BrowserTypeString =
| "mullvad-browser"
@@ -42,7 +35,8 @@ type BrowserTypeString =
| "chromium"
| "brave"
| "zen"
| "tor-browser";
| "tor-browser"
| "camoufox";
interface CreateProfileDialogProps {
isOpen: boolean;
@@ -53,503 +47,438 @@ interface CreateProfileDialogProps {
version: string;
releaseType: string;
proxyId?: string;
camoufoxConfig?: CamoufoxConfig;
}) => Promise<void>;
}
interface BrowserOption {
value: BrowserTypeString;
label: string;
description: string;
}
const browserOptions: BrowserOption[] = [
{
value: "firefox",
label: "Firefox",
description: "Mozilla's main web browser",
},
{
value: "firefox-developer",
label: "Firefox Developer Edition",
description: "Browser for developers with cutting-edge features",
},
{
value: "chromium",
label: "Chromium",
description: "Open-source version of Chrome",
},
{
value: "brave",
label: "Brave",
description: "Privacy-focused browser with ad blocking",
},
{
value: "zen",
label: "Zen Browser",
description: "Beautiful, customizable Firefox-based browser",
},
{
value: "mullvad-browser",
label: "Mullvad Browser",
description: "Privacy browser by Mullvad VPN",
},
{
value: "tor-browser",
label: "Tor Browser",
description: "Browse anonymously through the Tor network",
},
];
const getCurrentOS = () => {
if (typeof window !== "undefined") {
const userAgent = window.navigator.userAgent;
if (userAgent.includes("Win")) return "windows";
if (userAgent.includes("Mac")) return "macos";
if (userAgent.includes("Linux")) return "linux";
}
return "unknown";
};
export function CreateProfileDialog({
isOpen,
onClose,
onCreateProfile,
}: CreateProfileDialogProps) {
const [profileName, setProfileName] = useState("");
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>("mullvad-browser");
const [selectedReleaseType, setSelectedReleaseType] = useState<
"stable" | "nightly" | null
>(null);
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>({
stable: undefined,
nightly: undefined,
const [activeTab, setActiveTab] = useState("regular");
// Regular browser states
const [selectedBrowser, setSelectedBrowser] = useState<BrowserTypeString>();
const [selectedProxyId, setSelectedProxyId] = useState<string>();
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({
enable_cache: true, // Cache enabled by default
os: [getCurrentOS()], // Default to current OS
});
const [isCreating, setIsCreating] = useState(false);
const [existingProfiles, setExistingProfiles] = useState<BrowserProfile[]>(
[],
);
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
// Proxy settings - now using stored proxy selection
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(null);
// Common states
const [availableReleaseTypes, setAvailableReleaseTypes] =
useState<BrowserReleaseTypes>({});
const [camoufoxReleaseTypes, setCamoufoxReleaseTypes] =
useState<BrowserReleaseTypes>({});
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [isLoadingProxies, setIsLoadingProxies] = useState(false);
const [showProxyForm, setShowProxyForm] = useState(false);
const [isCreating, setIsCreating] = useState(false);
// Use the browser download hook
const {
isBrowserDownloading,
downloadBrowser,
isDownloading,
downloadedVersions,
loadDownloadedVersions,
isVersionDownloaded,
} = useBrowserDownload();
const {
supportedBrowsers,
isLoading: isLoadingSupport,
isBrowserSupported,
} = useBrowserSupport();
useEffect(() => {
if (supportedBrowsers.length > 0) {
// Set default browser to first supported browser
if (supportedBrowsers.includes("mullvad-browser")) {
setSelectedBrowser("mullvad-browser");
} else if (supportedBrowsers.length > 0) {
setSelectedBrowser(supportedBrowsers[0] as BrowserTypeString);
}
}
}, [supportedBrowsers]);
// Set default release type when release types are loaded
useEffect(() => {
if (!selectedReleaseType && Object.keys(releaseTypes).length > 0) {
// First try to set stable if it exists
if (releaseTypes.stable) {
setSelectedReleaseType("stable");
}
// If stable doesn't exist but nightly does, set nightly as default
else if (releaseTypes.nightly && selectedBrowser !== "chromium") {
setSelectedReleaseType("nightly");
}
}
}, [releaseTypes, selectedReleaseType, selectedBrowser]);
const loadExistingProfiles = useCallback(async () => {
const loadSupportedBrowsers = useCallback(async () => {
try {
const profiles = await invoke<BrowserProfile[]>("list_browser_profiles");
setExistingProfiles(profiles);
const browsers = await invoke<string[]>("get_supported_browsers");
setSupportedBrowsers(browsers);
} catch (error) {
console.error("Failed to load existing profiles:", error);
console.error("Failed to load supported browsers:", error);
}
}, []);
const loadStoredProxies = useCallback(async () => {
try {
setIsLoadingProxies(true);
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
setStoredProxies(proxies);
} catch (error) {
console.error("Failed to load stored proxies:", error);
toast.error("Failed to load available proxies");
} finally {
setIsLoadingProxies(false);
}
}, []);
const loadReleaseTypes = useCallback(async (browser: string) => {
try {
setIsLoadingReleaseTypes(true);
const types = await invoke<BrowserReleaseTypes>(
"get_browser_release_types",
{
browserStr: browser,
},
);
setReleaseTypes(types);
} catch (error) {
console.error("Failed to load release types:", error);
toast.error("Failed to load available versions");
} finally {
setIsLoadingReleaseTypes(false);
}
}, []);
const loadReleaseTypes = useCallback(
async (browser: string) => {
try {
const releaseTypes = await invoke<BrowserReleaseTypes>(
"get_browser_release_types",
{ browserStr: browser },
);
const handleDownload = useCallback(async () => {
if (!selectedBrowser || !selectedReleaseType) return;
if (browser === "camoufox") {
setCamoufoxReleaseTypes(releaseTypes);
} else {
setAvailableReleaseTypes(releaseTypes);
}
const version =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
if (!version) return;
await downloadBrowser(selectedBrowser, version);
}, [selectedBrowser, selectedReleaseType, downloadBrowser, releaseTypes]);
const validateProfileName = useCallback(
(name: string): string | null => {
const trimmedName = name.trim();
if (!trimmedName) {
return "Profile name cannot be empty";
// Load downloaded versions for this browser
await loadDownloadedVersions(browser);
} catch (error) {
console.error(`Failed to load release types for ${browser}:`, error);
}
// Check for duplicate names (case insensitive)
const isDuplicate = existingProfiles.some(
(profile) => profile.name.toLowerCase() === trimmedName.toLowerCase(),
);
if (isDuplicate) {
return "A profile with this name already exists";
}
return null;
},
[existingProfiles],
[loadDownloadedVersions],
);
// Helper to determine if proxy should be disabled for the selected browser
const isProxyDisabled = selectedBrowser === "tor-browser";
// Update proxy selection when browser changes to tor-browser
// Load data when dialog opens
useEffect(() => {
if (selectedBrowser === "tor-browser" && selectedProxyId) {
setSelectedProxyId(null);
if (isOpen) {
void loadSupportedBrowsers();
void loadStoredProxies();
// Load camoufox release types when dialog opens
void loadReleaseTypes("camoufox");
}
}, [selectedBrowser, selectedProxyId]);
}, [isOpen, loadSupportedBrowsers, loadStoredProxies, loadReleaseTypes]);
const handleCreateProxy = useCallback(() => {
setShowProxyForm(true);
}, []);
// Load release types when browser selection changes
useEffect(() => {
if (selectedBrowser) {
void loadReleaseTypes(selectedBrowser);
}
}, [selectedBrowser, loadReleaseTypes]);
const handleProxySaved = useCallback((savedProxy: StoredProxy) => {
setStoredProxies((prev) => {
const existingIndex = prev.findIndex((p) => p.id === savedProxy.id);
if (existingIndex >= 0) {
// Update existing proxy
const updated = [...prev];
updated[existingIndex] = savedProxy;
return updated;
} else {
// Add new proxy
return [...prev, savedProxy];
}
});
setSelectedProxyId(savedProxy.id);
setShowProxyForm(false);
}, []);
const handleDownload = async (browserStr: string) => {
const releaseTypes =
browserStr === "camoufox" ? camoufoxReleaseTypes : availableReleaseTypes;
const latestStableVersion = releaseTypes.stable;
const handleProxyFormClose = useCallback(() => {
setShowProxyForm(false);
}, []);
const handleCreate = useCallback(async () => {
if (!profileName.trim() || !selectedBrowser || !selectedReleaseType) return;
// Validate profile name
const nameError = validateProfileName(profileName);
if (nameError) {
toast.error(nameError);
if (!latestStableVersion) {
console.error("No stable version available for download");
return;
}
const version =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
if (!version) {
toast.error("Selected release type is not available");
return;
try {
await downloadBrowser(browserStr, latestStableVersion);
} catch (error) {
console.error("Failed to download browser:", error);
}
};
const handleCreate = async () => {
if (!profileName.trim()) return;
setIsCreating(true);
try {
await onCreateProfile({
name: profileName.trim(),
browserStr: selectedBrowser,
version,
releaseType: selectedReleaseType,
proxyId: isProxyDisabled ? undefined : (selectedProxyId ?? undefined),
});
if (activeTab === "regular") {
if (!selectedBrowser) {
console.error("Missing required browser selection");
return;
}
// Reset form
setProfileName("");
setSelectedReleaseType(null);
setSelectedProxyId(null);
onClose();
// Use the latest stable version by default
const latestStableVersion = availableReleaseTypes.stable;
if (!latestStableVersion) {
console.error("No stable version available");
return;
}
await onCreateProfile({
name: profileName.trim(),
browserStr: selectedBrowser,
version: latestStableVersion,
releaseType: "stable",
proxyId: selectedProxyId,
});
} else {
// Anti-detect tab - always use Camoufox with latest version
const latestCamoufoxVersion = camoufoxReleaseTypes.stable;
if (!latestCamoufoxVersion) {
console.error("No Camoufox version available");
return;
}
await onCreateProfile({
name: profileName.trim(),
browserStr: "camoufox" as BrowserTypeString,
version: latestCamoufoxVersion,
releaseType: "stable",
proxyId: selectedProxyId,
camoufoxConfig,
});
}
handleClose();
} catch (error) {
console.error("Failed to create profile:", error);
} finally {
setIsCreating(false);
}
}, [
profileName,
selectedBrowser,
selectedReleaseType,
onCreateProfile,
isProxyDisabled,
selectedProxyId,
onClose,
releaseTypes.nightly,
releaseTypes.stable,
validateProfileName,
]);
};
const nameError = profileName.trim()
? validateProfileName(profileName)
: null;
const handleClose = () => {
// Reset all states
setProfileName("");
setSelectedBrowser(undefined);
setSelectedProxyId(undefined);
setCamoufoxConfig({
enable_cache: true,
os: [getCurrentOS()], // Reset to current OS
});
setActiveTab("regular");
onClose();
};
const selectedVersion =
selectedReleaseType === "stable"
? releaseTypes.stable
: releaseTypes.nightly;
const isCreateDisabled = () => {
if (!profileName.trim()) return true;
const canCreate =
profileName.trim() &&
selectedBrowser &&
selectedReleaseType &&
selectedVersion &&
isVersionDownloaded(selectedVersion) &&
!nameError;
useEffect(() => {
if (isOpen) {
void loadExistingProfiles();
void loadStoredProxies();
if (activeTab === "regular") {
return !selectedBrowser || !availableReleaseTypes.stable;
} else {
// For anti-detect, we need camoufox to be available
return !camoufoxReleaseTypes.stable;
}
}, [isOpen, loadExistingProfiles, loadStoredProxies]);
};
useEffect(() => {
if (isOpen && selectedBrowser) {
// Reset selected release type when browser changes
setSelectedReleaseType(null);
void loadReleaseTypes(selectedBrowser);
void loadDownloadedVersions(selectedBrowser);
}
}, [isOpen, selectedBrowser, loadDownloadedVersions, loadReleaseTypes]);
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
};
// Check if browser version is downloaded and available
const isBrowserVersionAvailable = (browserStr: string) => {
const releaseTypes =
browserStr === "camoufox" ? camoufoxReleaseTypes : availableReleaseTypes;
const latestStableVersion = releaseTypes.stable;
return latestStableVersion && isVersionDownloaded(latestStableVersion);
};
// Get the selected OS for warning
const selectedOS = camoufoxConfig.os?.[0];
const currentOS = getCurrentOS();
const _showOSWarning =
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
</DialogHeader>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
</DialogHeader>
<div className="grid overflow-y-scroll flex-1 gap-6 py-4 min-h-0">
{/* Profile Name */}
<div className="grid gap-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => {
setProfileName(e.target.value);
}}
placeholder="Enter profile name"
className={nameError ? "border-red-500" : ""}
/>
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
</div>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col flex-1 w-full min-h-0"
>
<TabsList className="grid flex-shrink-0 grid-cols-2 w-full">
<TabsTrigger value="regular">Regular Browsers</TabsTrigger>
<TabsTrigger value="anti-detect">Anti-Detect</TabsTrigger>
</TabsList>
{/* Browser Selection */}
<div className="grid gap-2">
<Label>Browser</Label>
<Select
value={selectedBrowser ?? undefined}
onValueChange={(value) => {
setSelectedBrowser(value as BrowserTypeString);
}}
disabled={isLoadingSupport}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingSupport
? "Loading browsers..."
: "Select browser"
}
/>
</SelectTrigger>
<SelectContent>
{(
[
"mullvad-browser",
"firefox",
"firefox-developer",
"chromium",
"brave",
"zen",
"tor-browser",
] as BrowserTypeString[]
).map((browser) => {
const isSupported = isBrowserSupported(browser);
const displayName = getBrowserDisplayName(browser);
<ScrollArea className="flex-1 pr-6 h-[350px]">
<div className="py-4 space-y-6">
{/* Profile Name - Common to both tabs */}
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
placeholder="Enter profile name"
/>
</div>
if (!isSupported) {
return (
<Tooltip key={browser}>
<TooltipTrigger asChild>
<SelectItem
value={browser}
disabled={true}
className="opacity-50"
>
{displayName} (Not supported)
</SelectItem>
</TooltipTrigger>
<TooltipContent>
<p>
{displayName} is not supported on your current
platform or architecture.
</p>
</TooltipContent>
</Tooltip>
);
}
return (
<SelectItem key={browser} value={browser}>
{displayName}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{selectedBrowser ? (
<div className="grid gap-2">
<Label>Release Type</Label>
{isLoadingReleaseTypes ? (
<div className="text-sm text-muted-foreground">
Loading release types...
</div>
) : Object.keys(releaseTypes).length === 0 ? (
<Alert>
<AlertDescription>
No releases are available for{" "}
{getBrowserDisplayName(selectedBrowser)}.
</AlertDescription>
</Alert>
) : (
<div className="space-y-4">
{(!releaseTypes.stable || !releaseTypes.nightly) && (
<Alert>
<AlertDescription>
Only {(releaseTypes.stable && "Stable") ?? "Nightly"}{" "}
releases are available for{" "}
{getBrowserDisplayName(selectedBrowser)}.
</AlertDescription>
</Alert>
)}
<ReleaseTypeSelector
selectedReleaseType={selectedReleaseType}
onReleaseTypeSelect={setSelectedReleaseType}
availableReleaseTypes={releaseTypes}
browser={selectedBrowser}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select release type..."
downloadedVersions={downloadedVersions}
<TabsContent value="regular" className="mt-0 space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label>Browser</Label>
<Combobox
options={browserOptions
.filter((browser) =>
supportedBrowsers.includes(browser.value),
)
.map((browser) => {
const IconComponent = getBrowserIcon(browser.value);
return {
value: browser.value,
label: browser.label,
icon: IconComponent,
};
})}
value={selectedBrowser || ""}
onValueChange={(value) =>
setSelectedBrowser(value as BrowserTypeString)
}
placeholder="Select a browser..."
searchPlaceholder="Search browsers..."
/>
</div>
)}
</div>
) : null}
{/* Proxy Settings */}
<div className="grid gap-4 pt-4 border-t">
<div className="grid gap-2">
<div className="flex justify-between items-center">
<Label>Proxy Settings</Label>
{!isProxyDisabled && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create Proxy
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Create a new proxy configuration</p>
</TooltipContent>
</Tooltip>
{selectedBrowser && (
<div className="space-y-3">
{!isBrowserVersionAvailable(selectedBrowser) &&
availableReleaseTypes.stable && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
Latest stable version (
{availableReleaseTypes.stable}) needs to be
downloaded
</p>
<LoadingButton
onClick={() => handleDownload(selectedBrowser)}
isLoading={isBrowserDownloading(selectedBrowser)}
size="sm"
disabled={isBrowserDownloading(selectedBrowser)}
>
Download
</LoadingButton>
</div>
)}
{isBrowserVersionAvailable(selectedBrowser) && (
<div className="text-sm text-green-600">
Latest stable version (
{availableReleaseTypes.stable}) is available
</div>
)}
</div>
)}
</div>
</TabsContent>
{isProxyDisabled ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="p-3 bg-yellow-50 rounded-md border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
Tor Browser has its own built-in proxy system and
doesn&apos;t support additional proxy configuration.
<TabsContent value="anti-detect" className="mt-0 space-y-6">
{/* Anti-Detect Description */}
<div className="p-3 text-center bg-blue-50 rounded-md border border-blue-200 dark:bg-blue-950 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
Powered by Camoufox
</p>
</div>
<div className="space-y-6">
{/* Camoufox Download Status */}
{!isBrowserVersionAvailable("camoufox") &&
camoufoxReleaseTypes.stable && (
<div className="flex gap-3 items-center p-3 bg-amber-50 rounded-md border border-amber-200">
<p className="text-sm text-amber-800">
Camoufox version ({camoufoxReleaseTypes.stable}) needs
to be downloaded
</p>
<LoadingButton
onClick={() => handleDownload("camoufox")}
isLoading={isBrowserDownloading("camoufox")}
size="sm"
disabled={isBrowserDownloading("camoufox")}
>
Download
</LoadingButton>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Tor Browser manages its own proxy routing automatically
</p>
</TooltipContent>
</Tooltip>
) : (
)}
{isBrowserVersionAvailable("camoufox") && (
<div className="p-3 text-sm text-green-600 bg-green-50 rounded-md border border-green-200">
Camoufox version ({camoufoxReleaseTypes.stable}) is
available
</div>
)}
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
/>
</div>
</TabsContent>
{/* Proxy Selection - Common to both tabs - Compact without card */}
{storedProxies.length > 0 && (
<div className="space-y-3">
<Label>Proxy</Label>
<Select
value={selectedProxyId ?? "none"}
onValueChange={(value) => {
setSelectedProxyId(value === "none" ? null : value);
}}
disabled={isLoadingProxies}
value={selectedProxyId || "none"}
onValueChange={(value) =>
setSelectedProxyId(value === "none" ? undefined : value)
}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingProxies
? "Loading proxies..."
: "Select proxy (optional)"
}
/>
<SelectValue placeholder="No proxy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No Proxy</SelectItem>
<SelectItem value="none">No proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
{proxy.name} ({proxy.proxy_settings.proxy_type}://
{proxy.proxy_settings.host}:
{proxy.proxy_settings.port})
</SelectItem>
))}
</SelectContent>
</Select>
)}
{!isProxyDisabled &&
storedProxies.length === 0 &&
!isLoadingProxies && (
<p className="text-sm text-muted-foreground">
No saved proxies available. Use the "Create Proxy" button
above to create proxy configurations.
</p>
)}
</div>
</div>
)}
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<LoadingButton
onClick={handleCreate}
isLoading={isCreating}
onClick={() => void handleCreate()}
disabled={!canCreate}
disabled={isCreateDisabled()}
>
Create Profile
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
<ProxyFormDialog
isOpen={showProxyForm}
onClose={handleProxyFormClose}
onSave={handleProxySaved}
/>
</>
</Tabs>
</DialogContent>
</Dialog>
);
}
+35 -16
View File
@@ -240,26 +240,45 @@ export function UnifiedToast(props: ToastProps) {
)}
{/* App update progress */}
{type === "app-update" &&
progress &&
"percentage" in progress &&
stage === "downloading" && (
<div className="mt-2 space-y-1">
<div className="flex justify-between items-center">
<p className="flex-1 min-w-0 text-xs text-gray-600 dark:text-gray-300">
{progress.percentage.toFixed(1)}%
{progress.speed && `${progress.speed} MB/s`}
{progress.eta && `${progress.eta} remaining`}
</p>
</div>
{type === "app-update" && (
<div className="mt-2 space-y-1">
{/* Download progress with percentage */}
{progress &&
"percentage" in progress &&
stage === "downloading" && (
<>
<div className="flex justify-between items-center">
<p className="flex-1 min-w-0 text-xs text-gray-600 dark:text-gray-300">
{progress.percentage.toFixed(1)}%
{progress.speed && `${progress.speed} MB/s`}
{progress.eta && `${progress.eta} remaining`}
</p>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress.percentage}%` }}
/>
</div>
</>
)}
{/* Progress indicator for other stages */}
{(stage === "extracting" ||
stage === "installing" ||
stage === "completed") && (
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress.percentage}%` }}
className={`h-1.5 rounded-full transition-all duration-500 ${
stage === "completed"
? "bg-green-500 w-full"
: "bg-blue-500 w-full animate-pulse"
}`}
/>
</div>
</div>
)}
)}
</div>
)}
{/* Version update progress */}
{type === "version-update" &&
+26 -2
View File
@@ -142,7 +142,19 @@ export function ImportProfileDialog({
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to import profile: ${errorMessage}`);
// Check if error is about browser not being downloaded
if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(profile.browser);
toast.error(
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
{
duration: 8000,
},
);
} else {
toast.error(`Failed to import profile: ${errorMessage}`);
}
} finally {
setIsImporting(false);
}
@@ -183,7 +195,19 @@ export function ImportProfileDialog({
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to import profile: ${errorMessage}`);
// Check if error is about browser not being downloaded
if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(manualBrowserType);
toast.error(
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
{
duration: 8000,
},
);
} else {
toast.error(`Failed to import profile: ${errorMessage}`);
}
} finally {
setIsImporting(false);
}
+16 -1
View File
@@ -58,6 +58,7 @@ interface ProfilesDataTableProps {
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
onRenameProfile: (oldName: string, newName: string) => Promise<void>;
onChangeVersion: (profile: BrowserProfile) => void;
onConfigureCamoufox?: (profile: BrowserProfile) => void;
runningProfiles: Set<string>;
isUpdating?: (browser: string) => boolean;
onReloadProxyData?: () => void | Promise<void>;
@@ -71,6 +72,7 @@ export function ProfilesDataTable({
onDeleteProfile,
onRenameProfile,
onChangeVersion,
onConfigureCamoufox,
runningProfiles,
isUpdating = () => false,
onReloadProxyData,
@@ -447,7 +449,19 @@ export function ProfilesDataTable({
>
Configure Proxy
</DropdownMenuItem>
{!["chromium", "zen"].includes(profile.browser) && (
{profile.browser === "camoufox" && onConfigureCamoufox && (
<DropdownMenuItem
onClick={() => {
onConfigureCamoufox(profile);
}}
disabled={!isClient || isBrowserUpdating}
>
Configure Camoufox
</DropdownMenuItem>
)}
{!["chromium", "zen", "camoufox"].includes(
profile.browser,
) && (
<DropdownMenuItem
onClick={() => {
onChangeVersion(profile);
@@ -492,6 +506,7 @@ export function ProfilesDataTable({
onKillProfile,
onProxySettings,
onChangeVersion,
onConfigureCamoufox,
getProxyInfo,
hasProxy,
getProxyDisplayName,
+18 -13
View File
@@ -83,12 +83,21 @@ export function ProfileSelectorDialog({
return isRunning;
}
// For Mullvad browser: never allow if running
if (profile.browser === "mullvad-browser" && isRunning) {
return false;
// For Mullvad browser: Check if any Mullvad browser is running
if (profile.browser === "mullvad-browser") {
const runningMullvadProfiles = allProfiles.filter(
(p) => p.browser === "mullvad-browser" && runningProfiles.has(p.name),
);
// If no Mullvad browser is running, allow any Mullvad profile
if (runningMullvadProfiles.length === 0) {
return true;
}
// If Mullvad browser(s) are running, only allow the running one(s)
return isRunning;
}
// For other browsers: always allow
return true;
},
[],
@@ -148,18 +157,14 @@ export function ProfileSelectorDialog({
const getProfileTooltipContent = (profile: BrowserProfile): string => {
const isRunning = runningProfiles.has(profile.name);
if (profile.browser === "tor-browser") {
// If another TOR profile is running, this one is not available
if (
profile.browser === "tor-browser" ||
profile.browser === "mullvad-browser"
) {
// If another TOR/Mullvad profile is running, this one is not available
return "Only 1 instance can run at a time";
}
if (profile.browser === "mullvad-browser") {
if (isRunning) {
return "Only launching the browser is supported, opening them in a running browser is not yet available";
}
return "Only launching the browser is supported, opening them in a running browser is not yet available";
}
if (isRunning) {
return "URL will open in a new tab in the existing browser window";
}
@@ -0,0 +1,568 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { CamoufoxConfig } from "@/types";
const osOptions = [
{ value: "windows", label: "Windows" },
{ value: "macos", label: "macOS" },
{ value: "linux", label: "Linux" },
];
const timezoneOptions = [
{ value: "America/New_York", label: "America/New_York" },
{ value: "America/Los_Angeles", label: "America/Los_Angeles" },
{ value: "America/Chicago", label: "America/Chicago" },
{ value: "America/Denver", label: "America/Denver" },
{ value: "America/Phoenix", label: "America/Phoenix" },
{ value: "America/Toronto", label: "America/Toronto" },
{ value: "America/Vancouver", label: "America/Vancouver" },
{ value: "Europe/London", label: "Europe/London" },
{ value: "Europe/Paris", label: "Europe/Paris" },
{ value: "Europe/Berlin", label: "Europe/Berlin" },
{ value: "Europe/Rome", label: "Europe/Rome" },
{ value: "Europe/Madrid", label: "Europe/Madrid" },
{ value: "Europe/Amsterdam", label: "Europe/Amsterdam" },
{ value: "Europe/Zurich", label: "Europe/Zurich" },
{ value: "Europe/Vienna", label: "Europe/Vienna" },
{ value: "Europe/Warsaw", label: "Europe/Warsaw" },
{ value: "Europe/Prague", label: "Europe/Prague" },
{ value: "Europe/Stockholm", label: "Europe/Stockholm" },
{ value: "Europe/Copenhagen", label: "Europe/Copenhagen" },
{ value: "Europe/Helsinki", label: "Europe/Helsinki" },
{ value: "Europe/Oslo", label: "Europe/Oslo" },
{ value: "Europe/Brussels", label: "Europe/Brussels" },
{ value: "Europe/Dublin", label: "Europe/Dublin" },
{ value: "Europe/Lisbon", label: "Europe/Lisbon" },
{ value: "Europe/Athens", label: "Europe/Athens" },
{ value: "Europe/Budapest", label: "Europe/Budapest" },
{ value: "Europe/Bucharest", label: "Europe/Bucharest" },
{ value: "Europe/Sofia", label: "Europe/Sofia" },
{ value: "Europe/Kiev", label: "Europe/Kiev" },
{ value: "Europe/Moscow", label: "Europe/Moscow" },
{ value: "Asia/Tokyo", label: "Asia/Tokyo" },
{ value: "Asia/Seoul", label: "Asia/Seoul" },
{ value: "Asia/Shanghai", label: "Asia/Shanghai" },
{ value: "Asia/Hong_Kong", label: "Asia/Hong_Kong" },
{ value: "Asia/Singapore", label: "Asia/Singapore" },
{ value: "Asia/Bangkok", label: "Asia/Bangkok" },
{ value: "Asia/Jakarta", label: "Asia/Jakarta" },
{ value: "Asia/Manila", label: "Asia/Manila" },
{ value: "Asia/Kolkata", label: "Asia/Kolkata" },
{ value: "Asia/Dubai", label: "Asia/Dubai" },
{ value: "Asia/Riyadh", label: "Asia/Riyadh" },
{ value: "Asia/Tehran", label: "Asia/Tehran" },
{ value: "Asia/Jerusalem", label: "Asia/Jerusalem" },
{ value: "Asia/Istanbul", label: "Asia/Istanbul" },
{ value: "Australia/Sydney", label: "Australia/Sydney" },
{ value: "Australia/Melbourne", label: "Australia/Melbourne" },
{ value: "Australia/Brisbane", label: "Australia/Brisbane" },
{ value: "Australia/Perth", label: "Australia/Perth" },
{ value: "Australia/Adelaide", label: "Australia/Adelaide" },
{ value: "Pacific/Auckland", label: "Pacific/Auckland" },
{ value: "Pacific/Honolulu", label: "Pacific/Honolulu" },
{ value: "Africa/Cairo", label: "Africa/Cairo" },
{ value: "Africa/Johannesburg", label: "Africa/Johannesburg" },
{ value: "Africa/Lagos", label: "Africa/Lagos" },
{ value: "Africa/Nairobi", label: "Africa/Nairobi" },
{ value: "America/Sao_Paulo", label: "America/Sao_Paulo" },
{ value: "America/Buenos_Aires", label: "America/Buenos_Aires" },
{ value: "America/Lima", label: "America/Lima" },
{ value: "America/Bogota", label: "America/Bogota" },
{ value: "America/Santiago", label: "America/Santiago" },
{ value: "America/Caracas", label: "America/Caracas" },
{ value: "America/Mexico_City", label: "America/Mexico_City" },
];
const localeOptions = [
{ value: "en-US", label: "English (US)" },
{ value: "en-GB", label: "English (UK)" },
{ value: "en-CA", label: "English (Canada)" },
{ value: "en-AU", label: "English (Australia)" },
{ value: "fr-FR", label: "French (France)" },
{ value: "fr-CA", label: "French (Canada)" },
{ value: "de-DE", label: "German (Germany)" },
{ value: "de-AT", label: "German (Austria)" },
{ value: "de-CH", label: "German (Switzerland)" },
{ value: "es-ES", label: "Spanish (Spain)" },
{ value: "es-MX", label: "Spanish (Mexico)" },
{ value: "es-AR", label: "Spanish (Argentina)" },
{ value: "it-IT", label: "Italian (Italy)" },
{ value: "it-CH", label: "Italian (Switzerland)" },
{ value: "pt-BR", label: "Portuguese (Brazil)" },
{ value: "pt-PT", label: "Portuguese (Portugal)" },
{ value: "ru-RU", label: "Russian (Russia)" },
{ value: "zh-CN", label: "Chinese (Simplified)" },
{ value: "zh-TW", label: "Chinese (Traditional)" },
{ value: "ja-JP", label: "Japanese (Japan)" },
{ value: "ko-KR", label: "Korean (Korea)" },
{ value: "ar-SA", label: "Arabic (Saudi Arabia)" },
{ value: "ar-EG", label: "Arabic (Egypt)" },
{ value: "hi-IN", label: "Hindi (India)" },
{ value: "tr-TR", label: "Turkish (Turkey)" },
{ value: "pl-PL", label: "Polish (Poland)" },
{ value: "nl-NL", label: "Dutch (Netherlands)" },
{ value: "nl-BE", label: "Dutch (Belgium)" },
{ value: "sv-SE", label: "Swedish (Sweden)" },
{ value: "da-DK", label: "Danish (Denmark)" },
{ value: "no-NO", label: "Norwegian (Norway)" },
{ value: "fi-FI", label: "Finnish (Finland)" },
{ value: "he-IL", label: "Hebrew (Israel)" },
{ value: "th-TH", label: "Thai (Thailand)" },
{ value: "vi-VN", label: "Vietnamese (Vietnam)" },
{ value: "id-ID", label: "Indonesian (Indonesia)" },
{ value: "ms-MY", label: "Malay (Malaysia)" },
{ value: "uk-UA", label: "Ukrainian (Ukraine)" },
{ value: "cs-CZ", label: "Czech (Czech Republic)" },
{ value: "sk-SK", label: "Slovak (Slovakia)" },
{ value: "hu-HU", label: "Hungarian (Hungary)" },
{ value: "ro-RO", label: "Romanian (Romania)" },
{ value: "bg-BG", label: "Bulgarian (Bulgaria)" },
{ value: "hr-HR", label: "Croatian (Croatia)" },
{ value: "sr-RS", label: "Serbian (Serbia)" },
{ value: "sl-SI", label: "Slovenian (Slovenia)" },
{ value: "lt-LT", label: "Lithuanian (Lithuania)" },
{ value: "lv-LV", label: "Latvian (Latvia)" },
{ value: "et-EE", label: "Estonian (Estonia)" },
{ value: "el-GR", label: "Greek (Greece)" },
{ value: "ca-ES", label: "Catalan (Spain)" },
{ value: "eu-ES", label: "Basque (Spain)" },
{ value: "gl-ES", label: "Galician (Spain)" },
{ value: "is-IS", label: "Icelandic (Iceland)" },
{ value: "mt-MT", label: "Maltese (Malta)" },
];
const getCurrentOS = () => {
if (typeof window !== "undefined") {
const userAgent = window.navigator.userAgent;
if (userAgent.includes("Win")) return "windows";
if (userAgent.includes("Mac")) return "macos";
if (userAgent.includes("Linux")) return "linux";
}
return "unknown";
};
interface SystemLocale {
locale: string;
language: string;
country: string;
}
interface SystemTimezone {
timezone: string;
offset: string;
}
interface SharedCamoufoxConfigFormProps {
config: CamoufoxConfig;
onConfigChange: (key: keyof CamoufoxConfig, value: unknown) => void;
className?: string;
}
export function SharedCamoufoxConfigForm({
config,
onConfigChange,
className = "",
}: SharedCamoufoxConfigFormProps) {
const [systemLocale, setSystemLocale] = useState<SystemLocale | null>(null);
const [systemTimezone, setSystemTimezone] = useState<SystemTimezone | null>(
null,
);
const [isLoadingSystemDefaults, setIsLoadingSystemDefaults] = useState(true);
// Load system defaults on component mount
useEffect(() => {
const loadSystemDefaults = async () => {
try {
const [locale, timezone] = await Promise.all([
invoke<SystemLocale>("get_system_locale"),
invoke<SystemTimezone>("get_system_timezone"),
]);
setSystemLocale(locale);
setSystemTimezone(timezone);
} catch (error) {
console.error("Failed to load system defaults:", error);
// Set fallback defaults
setSystemLocale({
locale: "en-US",
language: "en",
country: "US",
});
setSystemTimezone({
timezone: "America/New_York",
offset: "-05:00",
});
} finally {
setIsLoadingSystemDefaults(false);
}
};
loadSystemDefaults();
}, []);
// Get the selected OS for warning
const selectedOS = config.os?.[0];
const currentOS = getCurrentOS();
const showOSWarning =
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
return (
<div className={`space-y-6 ${className}`}>
{/* OS Selection */}
<div className="space-y-3">
<Label>Operating System</Label>
<Select
value={config.os?.[0] || getCurrentOS()}
onValueChange={(value) => onConfigChange("os", [value])}
>
<SelectTrigger>
<SelectValue placeholder="Select OS" />
</SelectTrigger>
<SelectContent>
{osOptions.map((os) => (
<SelectItem key={os.value} value={os.value}>
{os.label}
</SelectItem>
))}
</SelectContent>
</Select>
{showOSWarning && (
<p className="text-sm text-yellow-600 dark:text-yellow-400">
Selected OS ({selectedOS}) differs from your current OS (
{currentOS}). This may affect fingerprinting effectiveness.
</p>
)}
</div>
{/* Privacy & Blocking */}
<div className="space-y-3">
<Label>Privacy & Blocking</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="block-images"
checked={config.block_images || false}
onCheckedChange={(checked) =>
onConfigChange("block_images", checked)
}
/>
<Label htmlFor="block-images">Block Images</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="block-webrtc"
checked={config.block_webrtc || false}
onCheckedChange={(checked) =>
onConfigChange("block_webrtc", checked)
}
/>
<Label htmlFor="block-webrtc">Block WebRTC</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="block-webgl"
checked={config.block_webgl || false}
onCheckedChange={(checked) =>
onConfigChange("block_webgl", checked)
}
/>
<Label htmlFor="block-webgl">Block WebGL</Label>
</div>
</div>
</div>
{/* Geolocation */}
<div className="space-y-3">
<Label>Geolocation</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<Input
id="country"
value={config.country || ""}
onChange={(e) =>
onConfigChange("country", e.target.value || undefined)
}
placeholder={
systemLocale
? `e.g., ${systemLocale.country}`
: "e.g., US, GB, DE"
}
/>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<Select
value={config.timezone || "auto"}
onValueChange={(value) =>
onConfigChange("timezone", value === "auto" ? undefined : value)
}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingSystemDefaults ? "Loading..." : "Select timezone"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{isLoadingSystemDefaults
? "Auto (loading...)"
: `Auto (${systemTimezone?.timezone || "UTC"})`}
</SelectItem>
{timezoneOptions.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="latitude">Latitude</Label>
<Input
id="latitude"
type="number"
step="any"
value={config.latitude || ""}
onChange={(e) =>
onConfigChange(
"latitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., 40.7128"
/>
</div>
<div className="space-y-2">
<Label htmlFor="longitude">Longitude</Label>
<Input
id="longitude"
type="number"
step="any"
value={config.longitude || ""}
onChange={(e) =>
onConfigChange(
"longitude",
e.target.value ? parseFloat(e.target.value) : undefined,
)
}
placeholder="e.g., -74.0060"
/>
</div>
</div>
</div>
{/* Localization */}
<div className="space-y-3">
<Label>Locale</Label>
<Select
value={config.locale?.[0] || ""}
onValueChange={(value) =>
onConfigChange("locale", value ? [value] : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingSystemDefaults
? "Loading..."
: `Select locale (system: ${systemLocale?.locale || "unknown"})`
}
/>
</SelectTrigger>
<SelectContent>
{!isLoadingSystemDefaults && systemLocale && (
<SelectItem value={systemLocale.locale}>
{systemLocale.locale} (System Default)
</SelectItem>
)}
{localeOptions.map((locale) => (
<SelectItem key={locale.value} value={locale.value}>
{locale.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Screen Resolution */}
<div className="space-y-3">
<Label>Screen Resolution</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-min-width">Min Width</Label>
<Input
id="screen-min-width"
type="number"
value={config.screen_min_width || ""}
onChange={(e) =>
onConfigChange(
"screen_min_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1024"
/>
</div>
<div className="space-y-2">
<Label htmlFor="screen-max-width">Max Width</Label>
<Input
id="screen-max-width"
type="number"
value={config.screen_max_width || ""}
onChange={(e) =>
onConfigChange(
"screen_max_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1920"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="screen-min-height">Min Height</Label>
<Input
id="screen-min-height"
type="number"
value={config.screen_min_height || ""}
onChange={(e) =>
onConfigChange(
"screen_min_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 768"
/>
</div>
<div className="space-y-2">
<Label htmlFor="screen-max-height">Max Height</Label>
<Input
id="screen-max-height"
type="number"
value={config.screen_max_height || ""}
onChange={(e) =>
onConfigChange(
"screen_max_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1080"
/>
</div>
</div>
</div>
{/* Window Size */}
<div className="space-y-3">
<Label>Window Size</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="window-width">Width</Label>
<Input
id="window-width"
type="number"
value={config.window_width || ""}
onChange={(e) =>
onConfigChange(
"window_width",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 1366"
/>
</div>
<div className="space-y-2">
<Label htmlFor="window-height">Height</Label>
<Input
id="window-height"
type="number"
value={config.window_height || ""}
onChange={(e) =>
onConfigChange(
"window_height",
e.target.value ? parseInt(e.target.value) : undefined,
)
}
placeholder="e.g., 768"
/>
</div>
</div>
</div>
{/* Advanced Options */}
<div className="space-y-3">
<Label>Advanced Options</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="enable-cache"
checked={config.enable_cache || false}
onCheckedChange={(checked) =>
onConfigChange("enable_cache", checked)
}
/>
<Label htmlFor="enable-cache">Enable Browser Cache</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="main-world-eval"
checked={config.main_world_eval || false}
onCheckedChange={(checked) =>
onConfigChange("main_world_eval", checked)
}
/>
<Label htmlFor="main-world-eval">
Enable Main World Evaluation
</Label>
</div>
</div>
</div>
{/* WebGL Settings */}
<div className="space-y-3">
<Label>WebGL Settings</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="webgl-vendor">WebGL Vendor</Label>
<Input
id="webgl-vendor"
value={config.webgl_vendor || ""}
onChange={(e) =>
onConfigChange("webgl_vendor", e.target.value || undefined)
}
placeholder="e.g., Intel Inc."
/>
</div>
<div className="space-y-2">
<Label htmlFor="webgl-renderer">WebGL Renderer</Label>
<Input
id="webgl-renderer"
value={config.webgl_renderer || ""}
onChange={(e) =>
onConfigChange("webgl_renderer", e.target.value || undefined)
}
placeholder="e.g., Intel HD Graphics"
/>
</div>
</div>
</div>
</div>
);
}
+79
View File
@@ -19,6 +19,85 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface ComboboxOption {
value: string;
label: string;
description?: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
className?: string;
}
export function Combobox({
options,
value,
onValueChange,
placeholder = "Select option...",
searchPlaceholder = "Search...",
className,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between", className)}
>
{value
? options.find((option) => option.value === value)?.label
: placeholder}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>No option found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onValueChange(currentValue === value ? "" : currentValue);
setOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-sm text-muted-foreground">
{option.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const frameworks = [
{
value: "next.js",
+55
View File
@@ -0,0 +1,55 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+1 -1
View File
@@ -120,7 +120,7 @@ export function useAppUpdateNotifications() {
setTimeout(() => {
setIsUpdating(false);
setUpdateProgress(null);
}, 2000);
}, 5000); // Show completion message for 5 seconds instead of 2
}
},
);
+46 -29
View File
@@ -247,41 +247,58 @@ export function useBrowserDownload() {
// Listen for download progress events
useEffect(() => {
const unlisten = listen<DownloadProgress>("download-progress", (event) => {
const progress = event.payload;
setDownloadProgress(progress);
let unlistenFn: (() => void) | null = null;
const browserName = getBrowserDisplayName(progress.browser);
const setupListener = async () => {
try {
unlistenFn = await listen<DownloadProgress>(
"download-progress",
(event) => {
const progress = event.payload;
setDownloadProgress(progress);
// Show toast with progress
if (progress.stage === "downloading") {
const speedMBps = (
progress.speed_bytes_per_sec /
(1024 * 1024)
).toFixed(1);
const etaText = progress.eta_seconds
? formatTime(progress.eta_seconds)
: "calculating...";
const browserName = getBrowserDisplayName(progress.browser);
showDownloadToast(browserName, progress.version, "downloading", {
percentage: progress.percentage,
speed: speedMBps,
eta: etaText,
});
} else if (progress.stage === "extracting") {
showDownloadToast(browserName, progress.version, "extracting");
} else if (progress.stage === "verifying") {
showDownloadToast(browserName, progress.version, "verifying");
} else if (progress.stage === "completed") {
showDownloadToast(browserName, progress.version, "completed");
setDownloadProgress(null);
// Show toast with progress
if (progress.stage === "downloading") {
const speedMBps = (
progress.speed_bytes_per_sec /
(1024 * 1024)
).toFixed(1);
const etaText = progress.eta_seconds
? formatTime(progress.eta_seconds)
: "calculating...";
showDownloadToast(browserName, progress.version, "downloading", {
percentage: progress.percentage,
speed: speedMBps,
eta: etaText,
});
} else if (progress.stage === "extracting") {
showDownloadToast(browserName, progress.version, "extracting");
} else if (progress.stage === "verifying") {
showDownloadToast(browserName, progress.version, "verifying");
} else if (progress.stage === "completed") {
showDownloadToast(browserName, progress.version, "completed");
setDownloadProgress(null);
}
},
);
} catch (error) {
console.error("Failed to setup download progress listener:", error);
}
});
};
setupListener();
return () => {
void unlisten.then((fn) => {
fn();
});
if (unlistenFn) {
try {
unlistenFn();
} catch (error) {
console.error("Failed to cleanup download progress listener:", error);
}
}
};
}, [formatTime]);
+176 -136
View File
@@ -61,167 +61,207 @@ export function useVersionUpdater() {
// Listen for version update progress events
useEffect(() => {
const unlisten = listen<VersionUpdateProgress>(
"version-update-progress",
(event) => {
const progress = event.payload;
setUpdateProgress(progress);
let unlistenFn: (() => void) | null = null;
if (progress.status === "updating") {
setIsUpdating(true);
const setupListener = async () => {
try {
unlistenFn = await listen<VersionUpdateProgress>(
"version-update-progress",
(event) => {
const progress = event.payload;
setUpdateProgress(progress);
// Show unified progress toast
const currentBrowserName = progress.current_browser
? getBrowserDisplayName(progress.current_browser)
: undefined;
if (progress.status === "updating") {
setIsUpdating(true);
showUnifiedVersionUpdateToast("Checking for browser updates...", {
description: currentBrowserName
? `Fetching ${currentBrowserName} release information...`
: "Initializing version check...",
progress: {
current: progress.completed_browsers,
total: progress.total_browsers,
found: progress.new_versions_found,
current_browser: currentBrowserName,
},
});
} else if (progress.status === "completed") {
setIsUpdating(false);
setUpdateProgress(null);
dismissToast("unified-version-update");
// Show unified progress toast
const currentBrowserName = progress.current_browser
? getBrowserDisplayName(progress.current_browser)
: undefined;
if (progress.new_versions_found > 0) {
showSuccessToast("Browser versions updated successfully", {
duration: 5000,
description:
"Auto-downloads will start shortly for available updates.",
});
} else {
showSuccessToast("No new browser versions found", {
duration: 3000,
description: "All browser versions are up to date",
});
}
showUnifiedVersionUpdateToast("Checking for browser updates...", {
description: currentBrowserName
? `Fetching ${currentBrowserName} release information...`
: "Initializing version check...",
progress: {
current: progress.completed_browsers,
total: progress.total_browsers,
found: progress.new_versions_found,
current_browser: currentBrowserName,
},
});
} else if (progress.status === "completed") {
setIsUpdating(false);
setUpdateProgress(null);
dismissToast("unified-version-update");
// Refresh status
void loadUpdateStatus();
} else if (progress.status === "error") {
setIsUpdating(false);
setUpdateProgress(null);
dismissToast("unified-version-update");
if (progress.new_versions_found > 0) {
showSuccessToast("Browser versions updated successfully", {
duration: 5000,
description:
"Auto-downloads will start shortly for available updates.",
});
} else {
showSuccessToast("No new browser versions found", {
duration: 3000,
description: "All browser versions are up to date",
});
}
showErrorToast("Failed to update browser versions", {
duration: 6000,
description: "Check your internet connection and try again",
});
}
},
);
// Refresh status
void loadUpdateStatus();
} else if (progress.status === "error") {
setIsUpdating(false);
setUpdateProgress(null);
dismissToast("unified-version-update");
showErrorToast("Failed to update browser versions", {
duration: 6000,
description: "Check your internet connection and try again",
});
}
},
);
} catch (error) {
console.error(
"Failed to setup version update progress listener:",
error,
);
}
};
setupListener();
return () => {
void unlisten.then((fn) => {
fn();
});
if (unlistenFn) {
try {
unlistenFn();
} catch (error) {
console.error(
"Failed to cleanup version update progress listener:",
error,
);
}
}
};
}, [loadUpdateStatus]);
// Listen for browser auto-update events
useEffect(() => {
const unlisten = listen<AutoUpdateEvent>(
"browser-auto-update-available",
(event) => {
const handleAutoUpdate = async () => {
const { browser, new_version, notification_id } = event.payload;
console.log("Browser auto-update event received:", event.payload);
let unlistenFn: (() => void) | null = null;
const browserDisplayName = getBrowserDisplayName(browser);
const setupListener = async () => {
try {
unlistenFn = await listen<AutoUpdateEvent>(
"browser-auto-update-available",
(event) => {
const handleAutoUpdate = async () => {
const { browser, new_version, notification_id } = event.payload;
console.log("Browser auto-update event received:", event.payload);
try {
// Show auto-update start notification
showAutoUpdateToast(browserDisplayName, new_version, {
description: `Downloading ${browserDisplayName} ${new_version} automatically. Progress will be shown below.`,
});
const browserDisplayName = getBrowserDisplayName(browser);
// Dismiss the update notification in the backend
await invoke("dismiss_update_notification", {
notificationId: notification_id,
});
try {
// Show auto-update start notification
showAutoUpdateToast(browserDisplayName, new_version, {
description: `Downloading ${browserDisplayName} ${new_version} automatically. Progress will be shown below.`,
});
// Check if browser already exists before downloading
const isDownloaded = await invoke<boolean>("check_browser_exists", {
browserStr: browser,
version: new_version,
});
// Dismiss the update notification in the backend
await invoke("dismiss_update_notification", {
notificationId: notification_id,
});
if (isDownloaded) {
// Browser already exists, skip download and go straight to profile update
console.log(
`${browserDisplayName} ${new_version} already exists, skipping download`,
);
// Check if browser already exists before downloading
const isDownloaded = await invoke<boolean>(
"check_browser_exists",
{
browserStr: browser,
version: new_version,
},
);
showSuccessToast(
`${browserDisplayName} ${new_version} already available`,
{
description: "Updating profile configurations...",
duration: 3000,
},
);
} else {
// Download the browser - this will trigger download progress events automatically
await invoke("download_browser", {
browserStr: browser,
version: new_version,
});
}
if (isDownloaded) {
// Browser already exists, skip download and go straight to profile update
console.log(
`${browserDisplayName} ${new_version} already exists, skipping download`,
);
// Complete the update with auto-update of profile versions
const updatedProfiles = await invoke<string[]>(
"complete_browser_update_with_auto_update",
{
browser,
newVersion: new_version,
},
);
showSuccessToast(
`${browserDisplayName} ${new_version} already available`,
{
description: "Updating profile configurations...",
duration: 3000,
},
);
} else {
// Download the browser - this will trigger download progress events automatically
await invoke("download_browser", {
browserStr: browser,
version: new_version,
});
}
// Show success message based on whether profiles were updated
if (updatedProfiles.length > 0) {
const profileText =
updatedProfiles.length === 1
? `Profile "${updatedProfiles[0]}" has been updated`
: `${updatedProfiles.length} profiles have been updated`;
// Complete the update with auto-update of profile versions
const updatedProfiles = await invoke<string[]>(
"complete_browser_update_with_auto_update",
{
browser,
newVersion: new_version,
},
);
showSuccessToast(`${browserDisplayName} update completed`, {
description: `${profileText} to version ${new_version}. You can now launch your browsers with the latest version.`,
duration: 6000,
});
} else {
showSuccessToast(`${browserDisplayName} update completed`, {
description: `Version ${new_version} is now available. Running profiles will use the new version when restarted.`,
duration: 6000,
});
}
} catch (error) {
console.error("Failed to handle browser auto-update:", error);
showErrorToast(`Failed to auto-update ${browserDisplayName}`, {
description:
error instanceof Error
? error.message
: "Unknown error occurred",
duration: 8000,
});
}
};
// Show success message based on whether profiles were updated
if (updatedProfiles.length > 0) {
const profileText =
updatedProfiles.length === 1
? `Profile "${updatedProfiles[0]}" has been updated`
: `${updatedProfiles.length} profiles have been updated`;
// Call the async handler
void handleAutoUpdate();
},
);
showSuccessToast(`${browserDisplayName} update completed`, {
description: `${profileText} to version ${new_version}. You can now launch your browsers with the latest version.`,
duration: 6000,
});
} else {
showSuccessToast(`${browserDisplayName} update completed`, {
description: `Version ${new_version} is now available. Running profiles will use the new version when restarted.`,
duration: 6000,
});
}
} catch (error) {
console.error("Failed to handle browser auto-update:", error);
showErrorToast(`Failed to auto-update ${browserDisplayName}`, {
description:
error instanceof Error
? error.message
: "Unknown error occurred",
duration: 8000,
});
}
};
// Call the async handler
void handleAutoUpdate();
},
);
} catch (error) {
console.error("Failed to setup browser auto-update listener:", error);
}
};
setupListener();
return () => {
void unlisten.then((fn) => {
fn();
});
if (unlistenFn) {
try {
unlistenFn();
} catch (error) {
console.error(
"Failed to cleanup browser auto-update listener:",
error,
);
}
}
};
}, []);
+4 -1
View File
@@ -3,7 +3,7 @@
* Centralized helpers for browser name mapping, icons, etc.
*/
import { FaChrome, FaFirefox } from "react-icons/fa";
import { FaChrome, FaFirefox, FaShieldAlt } from "react-icons/fa";
import { SiBrave, SiMullvad, SiTorbrowser } from "react-icons/si";
import { ZenBrowser } from "@/components/icons/zen-browser";
@@ -19,6 +19,7 @@ export function getBrowserDisplayName(browserType: string): string {
brave: "Brave",
chromium: "Chromium",
"tor-browser": "Tor Browser",
camoufox: "Anti-Detect",
};
return browserNames[browserType] || browserType;
@@ -42,6 +43,8 @@ export function getBrowserIcon(browserType: string) {
return ZenBrowser;
case "tor-browser":
return SiTorbrowser;
case "camoufox":
return FaShieldAlt;
default:
return null;
}
+47
View File
@@ -20,6 +20,7 @@ export interface BrowserProfile {
process_id?: number;
last_launch?: number;
release_type: string; // "stable" or "nightly"
camoufox_config?: CamoufoxConfig; // Camoufox configuration
}
export interface StoredProxy {
@@ -56,3 +57,49 @@ export interface AppUpdateProgress {
eta?: string; // estimated time remaining
message: string;
}
export interface CamoufoxConfig {
os?: string[];
block_images?: boolean;
block_webrtc?: boolean;
block_webgl?: boolean;
disable_coop?: boolean;
geoip?: string | boolean;
country?: string;
timezone?: string;
latitude?: number;
longitude?: number;
humanize?: boolean;
humanize_duration?: number;
headless?: boolean;
locale?: string[];
addons?: string[];
fonts?: string[];
custom_fonts_only?: boolean;
exclude_addons?: string[];
screen_min_width?: number;
screen_max_width?: number;
screen_min_height?: number;
screen_max_height?: number;
window_width?: number;
window_height?: number;
ff_version?: number;
main_world_eval?: boolean;
webgl_vendor?: string;
webgl_renderer?: string;
proxy?: string;
enable_cache?: boolean;
virtual_display?: string;
debug?: boolean;
additional_args?: string[];
env_vars?: Record<string, string>;
firefox_prefs?: Record<string, unknown>;
}
export interface CamoufoxLaunchResult {
id: string;
pid?: number;
executable_path: string;
profile_path: string;
url?: string;
}