Compare commits

...

7 Commits

Author SHA1 Message Date
zhom 57e17b46e9 chore: version bump 2026-03-20 02:45:11 +04:00
zhom 116a54942d refactor: networking 2026-03-20 02:45:11 +04:00
dependabot[bot] 8936816613 deps(deps): bump next from 16.1.6 to 16.1.7 (#239)
Bumps [next](https://github.com/vercel/next.js) from 16.1.6 to 16.1.7.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.6...v16.1.7)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.1.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 23:23:58 +00:00
zhom db05ffdef6 chore: version bump 2026-03-17 13:18:56 +04:00
zhom 96614a3f33 refactor: better tombstone handling 2026-03-17 13:15:48 +04:00
zhom 222a8b89f5 chore: version bump 2026-03-16 23:21:17 +04:00
zhom 69e68a7331 chore: don't try to detect magic bits fro dmg 2026-03-16 23:20:34 +04:00
21 changed files with 468 additions and 296 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.17.2",
"version": "0.17.5",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -60,7 +60,7 @@
"i18next": "^25.8.18",
"lucide-react": "^0.577.0",
"motion": "^12.36.0",
"next": "^16.1.6",
"next": "^16.1.7",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
+51 -51
View File
@@ -93,8 +93,8 @@ importers:
specifier: ^12.36.0
version: 12.36.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next:
specifier: ^16.1.6
version: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
specifier: ^16.1.7
version: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1362,57 +1362,57 @@ packages:
'@nestjs/platform-express':
optional: true
'@next/env@16.1.6':
resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
'@next/env@16.1.7':
resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==}
'@next/swc-darwin-arm64@16.1.6':
resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==}
'@next/swc-darwin-arm64@16.1.7':
resolution: {integrity: sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.1.6':
resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==}
'@next/swc-darwin-x64@16.1.7':
resolution: {integrity: sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.1.6':
resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==}
'@next/swc-linux-arm64-gnu@16.1.7':
resolution: {integrity: sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
'@next/swc-linux-arm64-musl@16.1.7':
resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
'@next/swc-linux-x64-gnu@16.1.7':
resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
'@next/swc-linux-x64-musl@16.1.7':
resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
'@next/swc-win32-arm64-msvc@16.1.7':
resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.1.6':
resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==}
'@next/swc-win32-x64-msvc@16.1.7':
resolution: {integrity: sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -3265,8 +3265,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.7:
resolution: {integrity: sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==}
baseline-browser-mapping@2.10.8:
resolution: {integrity: sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -3343,8 +3343,8 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
caniuse-lite@1.0.30001778:
resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==}
caniuse-lite@1.0.30001780:
resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -4590,8 +4590,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.1.6:
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
next@16.1.7:
resolution: {integrity: sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -6982,30 +6982,30 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
'@next/env@16.1.6': {}
'@next/env@16.1.7': {}
'@next/swc-darwin-arm64@16.1.6':
'@next/swc-darwin-arm64@16.1.7':
optional: true
'@next/swc-darwin-x64@16.1.6':
'@next/swc-darwin-x64@16.1.7':
optional: true
'@next/swc-linux-arm64-gnu@16.1.6':
'@next/swc-linux-arm64-gnu@16.1.7':
optional: true
'@next/swc-linux-arm64-musl@16.1.6':
'@next/swc-linux-arm64-musl@16.1.7':
optional: true
'@next/swc-linux-x64-gnu@16.1.6':
'@next/swc-linux-x64-gnu@16.1.7':
optional: true
'@next/swc-linux-x64-musl@16.1.6':
'@next/swc-linux-x64-musl@16.1.7':
optional: true
'@next/swc-win32-arm64-msvc@16.1.6':
'@next/swc-win32-arm64-msvc@16.1.7':
optional: true
'@next/swc-win32-x64-msvc@16.1.6':
'@next/swc-win32-x64-msvc@16.1.7':
optional: true
'@noble/hashes@1.8.0': {}
@@ -8916,7 +8916,7 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.7: {}
baseline-browser-mapping@2.10.8: {}
bl@4.1.0:
dependencies:
@@ -8959,8 +8959,8 @@ snapshots:
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.10.7
caniuse-lite: 1.0.30001778
baseline-browser-mapping: 2.10.8
caniuse-lite: 1.0.30001780
electron-to-chromium: 1.5.313
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
@@ -9004,7 +9004,7 @@ snapshots:
camelcase@6.3.0: {}
caniuse-lite@1.0.30001778: {}
caniuse-lite@1.0.30001780: {}
chalk@4.1.2:
dependencies:
@@ -10351,25 +10351,25 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.1.6
'@next/env': 16.1.7
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.7
caniuse-lite: 1.0.30001778
baseline-browser-mapping: 2.10.8
caniuse-lite: 1.0.30001780
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(react@19.2.4)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.6
'@next/swc-darwin-x64': 16.1.6
'@next/swc-linux-arm64-gnu': 16.1.6
'@next/swc-linux-arm64-musl': 16.1.6
'@next/swc-linux-x64-gnu': 16.1.6
'@next/swc-linux-x64-musl': 16.1.6
'@next/swc-win32-arm64-msvc': 16.1.6
'@next/swc-win32-x64-msvc': 16.1.6
'@next/swc-darwin-arm64': 16.1.7
'@next/swc-darwin-x64': 16.1.7
'@next/swc-linux-arm64-gnu': 16.1.7
'@next/swc-linux-arm64-musl': 16.1.7
'@next/swc-linux-x64-gnu': 16.1.7
'@next/swc-linux-x64-musl': 16.1.7
'@next/swc-win32-arm64-msvc': 16.1.7
'@next/swc-win32-x64-msvc': 16.1.7
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
+1 -1
View File
@@ -1717,7 +1717,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.17.2"
version = "0.17.5"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.17.2"
version = "0.17.5"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+3 -1
View File
@@ -704,7 +704,8 @@ impl AppAutoUpdater {
let total_size = response.content_length().unwrap_or(0);
log::info!("Silent download size: {} bytes", total_size);
let mut file = fs::File::create(&file_path)?;
let raw_file = fs::File::create(&file_path)?;
let mut file = std::io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file);
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
@@ -712,6 +713,7 @@ impl AppAutoUpdater {
let chunk = chunk?;
file.write_all(&chunk)?;
}
std::io::Write::flush(&mut file)?;
log::info!("Silent download completed: {}", file_path.display());
Ok(file_path)
+8
View File
@@ -145,6 +145,14 @@ impl AutoUpdater {
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
// Skip if this browser-version pair is already being downloaded
if crate::downloader::is_downloading(&browser, &new_version) {
log::info!(
"Browser {browser} {new_version} is already being downloaded, skipping duplicate"
);
return;
}
if registry.is_browser_downloaded(&browser, &new_version) {
log::info!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
+9 -18
View File
@@ -2157,14 +2157,11 @@ impl BrowserRunner {
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
if profile.is_cross_os() {
return Err(format!(
"Cannot open URL with profile '{}': cross-OS fingerprints require a paid subscription",
"Cannot open URL with profile '{}': this profile was created on {} and cannot be used on a different operating system",
profile.name,
profile.host_os.as_deref().unwrap_or("another OS"),
));
}
@@ -2196,14 +2193,11 @@ pub async fn launch_browser_profile(
profile.id
);
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
if profile.is_cross_os() {
return Err(format!(
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
"Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system",
profile.name,
profile.host_os.as_deref().unwrap_or("another OS"),
));
}
@@ -2516,14 +2510,11 @@ pub async fn launch_browser_profile_with_debugging(
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
if profile.is_cross_os() {
return Err(format!(
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
"Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system",
profile.name,
profile.host_os.as_deref().unwrap_or("another OS"),
));
}
+32 -32
View File
@@ -308,40 +308,12 @@ impl Downloader {
.resolve_download_url(browser_type.clone(), version, download_info)
.await?;
// Check existing file size — if it matches the expected size, skip download
// Determine if we have a partial file to resume
let mut existing_size: u64 = 0;
if let Ok(meta) = std::fs::metadata(&file_path) {
existing_size = meta.len();
}
// Do a HEAD request to get the expected file size for skip/resume decisions
let head_response = self
.client
.head(&download_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
.ok();
let expected_size = head_response.as_ref().and_then(|r| r.content_length());
// If existing file matches expected size, skip download entirely
if existing_size > 0 {
if let Some(expected) = expected_size {
if existing_size == expected {
log::info!(
"Archive {} already exists with correct size ({} bytes), skipping download",
file_path.display(),
existing_size
);
return Ok(file_path);
}
}
}
// Build request, add Range only if we have bytes. If the server responds with 416 (Range Not
// Satisfiable), delete the partial file and retry once without the Range header.
let response = {
@@ -415,6 +387,20 @@ impl Downloader {
existing_size = 0;
}
// If the existing file already matches the total size, skip the download
if existing_size > 0 {
if let Some(total) = total_size {
if existing_size >= total {
log::info!(
"Archive {} already complete ({} bytes), skipping download",
file_path.display(),
existing_size
);
return Ok(file_path);
}
}
}
let mut downloaded = existing_size;
let start_time = std::time::Instant::now();
let mut last_update = start_time;
@@ -445,12 +431,16 @@ impl Downloader {
let _ = events::emit("download-progress", &progress);
// Open file in append mode (resuming) or create new
// Open file in append mode (resuming) or create new.
// Wrap in BufWriter with a large buffer to reduce the number of disk writes,
// which dramatically improves download speed on Windows (NTFS + Defender overhead).
use std::fs::OpenOptions;
let mut file = OpenOptions::new()
use std::io::Write;
let raw_file = OpenOptions::new()
.create(true)
.append(true)
.open(&file_path)?;
let mut file = io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file);
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
@@ -463,7 +453,7 @@ impl Downloader {
}
}
let chunk = chunk?;
io::copy(&mut chunk.as_ref(), &mut file)?;
file.write_all(&chunk)?;
downloaded += chunk.len() as u64;
let now = std::time::Instant::now();
@@ -510,6 +500,9 @@ impl Downloader {
}
}
// Flush remaining buffered data to disk
file.flush()?;
Ok(file_path)
}
@@ -953,6 +946,13 @@ impl Downloader {
}
}
/// Check if a specific browser-version pair is currently being downloaded
pub fn is_downloading(browser: &str, version: &str) -> bool {
let download_key = format!("{browser}-{version}");
let downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.contains(&download_key)
}
#[tauri::command]
pub async fn download_browser(
app_handle: tauri::AppHandle,
+12 -4
View File
@@ -232,13 +232,21 @@ impl Extractor {
&self,
file_path: &Path,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Always check magic bytes first — the file extension may be wrong
// (e.g. CDN serving a ZIP with .dmg extension)
// Check file extension first for container formats (DMG, MSI) whose internal
// compression makes magic bytes unreliable
if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) {
match ext.to_lowercase().as_str() {
"dmg" => return Ok("dmg".to_string()),
"msi" => return Ok("msi".to_string()),
_ => {}
}
}
let mut file = File::open(file_path)?;
let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection
let mut buffer = [0u8; 12];
file.read_exact(&mut buffer)?;
// Check magic numbers for different file types
// Check magic numbers for other file types
match &buffer[0..4] {
[0x50, 0x4B, 0x03, 0x04] | [0x50, 0x4B, 0x05, 0x06] | [0x50, 0x4B, 0x07, 0x08] => {
return Ok("zip".to_string())
+1 -1
View File
@@ -297,7 +297,7 @@ async fn fetch_dynamic_proxy(
.fetch_dynamic_proxy(&url, &format)
.await?;
// Validate the proxy actually works by routing through a temporary local proxy
// Validate the proxy actually works by connecting through it
crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity("_dynamic_test", &settings)
.await
+38 -2
View File
@@ -425,8 +425,21 @@ impl ProfileManager {
if path.is_dir() {
let metadata_file = path.join("metadata.json");
if metadata_file.exists() {
let content = fs::read_to_string(metadata_file)?;
let profile: BrowserProfile = serde_json::from_str(&content)?;
let content = fs::read_to_string(&metadata_file)?;
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
// Backfill host_os from browser config for profiles created before
// the field existed (or synced without it).
if profile.host_os.is_none() {
let inferred_os = profile.resolved_os().map(str::to_string);
if let Some(os) = inferred_os {
profile.host_os = Some(os);
if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = fs::write(&metadata_file, json);
}
}
}
profiles.push(profile);
}
}
@@ -566,6 +579,29 @@ impl ProfileManager {
Ok(())
}
/// Delete a profile from the local filesystem only, without triggering remote sync deletion.
/// Used when a profile was deleted on another device and the local copy should be cleaned up.
pub fn delete_profile_local_only(
&self,
profile_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let profiles_dir = self.get_profiles_dir();
let profile_dir = profiles_dir.join(profile_id);
if profile_dir.exists() {
fs::remove_dir_all(&profile_dir)?;
log::info!("Deleted local profile {} (tombstoned remotely)", profile_id);
}
if let Err(e) = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance()
.cleanup_unused_binaries()
{
log::warn!("Failed to cleanup binaries after tombstone deletion: {e}");
}
let _ = crate::events::emit_empty("profiles-changed");
Ok(())
}
pub fn update_profile_version(
&self,
_app_handle: &tauri::AppHandle,
+14 -3
View File
@@ -87,11 +87,22 @@ impl BrowserProfile {
profiles_dir.join(self.id.to_string()).join("profile")
}
/// Resolve the OS this profile was created on. Checks `host_os` first,
/// then falls back to the fingerprint config's `os` field (for profiles
/// created before `host_os` was introduced or synced without it).
pub fn resolved_os(&self) -> Option<&str> {
self
.host_os
.as_deref()
.or_else(|| self.camoufox_config.as_ref().and_then(|c| c.os.as_deref()))
.or_else(|| self.wayfern_config.as_ref().and_then(|c| c.os.as_deref()))
}
/// Returns true when the profile was created on a different OS than the current host.
/// Profiles without an `os` field (backward compat) are treated as native.
/// Checks `host_os` first, then falls back to the browser config's `os` field.
pub fn is_cross_os(&self) -> bool {
match &self.host_os {
Some(host_os) => host_os != &get_host_os(),
match self.resolved_os() {
Some(os) => os != get_host_os(),
None => false,
}
}
+3 -1
View File
@@ -1035,7 +1035,9 @@ Path=test.profile
fn test_get_default_version_for_browser_no_versions() {
let (importer, _temp_dir) = create_test_profile_importer();
let result = importer.get_default_version_for_browser("camoufox");
// Use a browser name that is guaranteed to have no downloaded versions,
// since the global registry singleton may contain real data from the system.
let result = importer.get_default_version_for_browser("nonexistent_browser_xyz");
assert!(
result.is_err(),
"Should fail when no versions are available"
+99 -16
View File
@@ -907,6 +907,63 @@ impl ProxyManager {
.map(|p| p.proxy_settings.clone())
}
fn classify_proxy_error(raw_error: &str, settings: &ProxySettings) -> String {
let err = raw_error.to_lowercase();
let proxy_addr = format!("{}:{}", settings.host, settings.port);
if err.contains("connection refused") {
return format!(
"Connection refused by {proxy_addr}. The proxy server is not accepting connections."
);
}
if err.contains("connection reset") {
return format!(
"Connection reset by {proxy_addr}. The proxy server closed the connection unexpectedly."
);
}
if err.contains("timed out") || err.contains("deadline has elapsed") {
return format!("Connection to {proxy_addr} timed out. The proxy server is not responding.");
}
if err.contains("no such host") || err.contains("dns") || err.contains("resolve") {
return format!(
"Could not resolve proxy host '{}'. Check that the hostname is correct.",
settings.host
);
}
if err.contains("authentication") || err.contains("407") || err.contains("proxy auth") {
return format!(
"Proxy authentication failed for {proxy_addr}. Check your username and password."
);
}
if err.contains("403") || err.contains("forbidden") {
return format!("Access denied by {proxy_addr} (403 Forbidden).");
}
if err.contains("402") {
return format!(
"Payment required by {proxy_addr} (402). Your proxy subscription may have expired."
);
}
if err.contains("502") || err.contains("bad gateway") {
return format!(
"Bad gateway from {proxy_addr} (502). The upstream proxy server may be down."
);
}
if err.contains("503") || err.contains("service unavailable") {
return format!("Proxy {proxy_addr} is temporarily unavailable (503).");
}
if err.contains("socks") && err.contains("unreachable") {
return format!("SOCKS proxy {proxy_addr} could not reach the target. The proxy server may not have internet access.");
}
if err.contains("invalid proxy") || err.contains("unsupported proxy") {
return format!(
"Invalid proxy configuration for {proxy_addr}. Check the proxy type and address."
);
}
// Generic fallback — still show the proxy address for context
format!("Proxy check failed for {proxy_addr}. Could not connect through the proxy.")
}
// Build proxy URL string from ProxySettings
fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
let mut url = format!("{}://", proxy_settings.proxy_type);
@@ -928,9 +985,8 @@ impl ProxyManager {
url
}
// Check if a proxy is valid by routing through a temporary local donut-proxy.
// This tests the exact same code path the browser uses, ensuring that if the
// check passes, the browser connection will work too.
// Check if a proxy is valid by routing through a temporary in-process donut-proxy.
// This tests the same code path the browser uses (local proxy -> upstream).
pub async fn check_proxy_validity(
&self,
proxy_id: &str,
@@ -938,19 +994,41 @@ impl ProxyManager {
) -> Result<ProxyCheckResult, String> {
let upstream_url = Self::build_proxy_url(proxy_settings);
// Start a temporary local proxy that tunnels through the upstream
let proxy_config = crate::proxy_runner::start_proxy_process(Some(upstream_url), None)
// Bind a temporary local proxy server in-process (no child process needed)
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to start test proxy: {e}"))?;
.map_err(|e| format!("Failed to bind test proxy: {e}"))?;
let local_port = listener
.local_addr()
.map_err(|e| format!("Failed to get local address: {e}"))?
.port();
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
let proxy_id_clone = proxy_config.id.clone();
let upstream_for_task = upstream_url.clone();
let proxy_task = tokio::spawn(async move {
use crate::proxy_server::BypassMatcher;
let bypass_matcher = BypassMatcher::new(&[]);
let upstream = Some(upstream_for_task);
// Accept up to 10 connections (enough for IP check which tries multiple endpoints)
for _ in 0..10 {
let accept =
tokio::time::timeout(std::time::Duration::from_secs(15), listener.accept()).await;
match accept {
Ok(Ok((stream, _))) => {
let upstream = upstream.clone();
let matcher = bypass_matcher.clone();
tokio::spawn(async move {
crate::proxy_server::handle_proxy_connection(stream, upstream, matcher).await;
});
}
_ => break,
}
}
});
// Fetch public IP through the local proxy (same path the browser uses)
let local_url = format!("http://127.0.0.1:{local_port}");
let ip_result = ip_utils::fetch_public_ip(Some(&local_url)).await;
// Stop the temporary proxy regardless of result
let _ = crate::proxy_runner::stop_proxy_process(&proxy_id_clone).await;
proxy_task.abort();
let ip = match ip_result {
Ok(ip) => ip,
@@ -964,7 +1042,10 @@ impl ProxyManager {
is_valid: false,
};
let _ = self.save_proxy_check_cache(proxy_id, &failed_result);
return Err(format!("Failed to fetch public IP: {e}"));
let err_str = e.to_string();
let user_message = Self::classify_proxy_error(&err_str, proxy_settings);
return Err(user_message);
}
};
@@ -2741,17 +2822,19 @@ mod tests {
fn test_process_running_detection_with_child_lifecycle() {
use crate::proxy_storage::is_process_running;
// Spawn a long-lived child so we can check while it runs
let mut child = std::process::Command::new(if cfg!(windows) { "timeout" } else { "sleep" })
// Spawn a long-lived child so we can check while it runs.
// On Windows, `timeout` requires console input and exits immediately in
// non-interactive contexts, so use `ping` with a high count instead.
let mut child = std::process::Command::new(if cfg!(windows) { "ping" } else { "sleep" })
.args(if cfg!(windows) {
vec!["/T", "10"]
vec!["-n", "100", "127.0.0.1"]
} else {
vec!["10"]
})
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn sleep");
.expect("spawn long-lived child");
let pid = child.id();
+83 -136
View File
@@ -883,6 +883,87 @@ fn build_reqwest_client_with_proxy(
Ok(client_builder.proxy(proxy).build()?)
}
/// Handle a single proxy connection (used by both the proxy worker and in-process proxy checks).
pub async fn handle_proxy_connection(
mut stream: tokio::net::TcpStream,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) {
let _ = stream.set_nodelay(true);
if stream.readable().await.is_err() {
return;
}
let mut peek_buffer = [0u8; 16];
match stream.read(&mut peek_buffer).await {
Ok(0) => {}
Ok(n) => {
let request_start_upper = String::from_utf8_lossy(&peek_buffer[..n.min(7)]).to_uppercase();
let is_connect = request_start_upper.starts_with("CONNECT");
if is_connect {
let mut full_request = Vec::with_capacity(4096);
full_request.extend_from_slice(&peek_buffer[..n]);
let mut remaining = [0u8; 4096];
let mut total_read = n;
let max_reads = 100;
let mut reads = 0;
loop {
if reads >= max_reads {
break;
}
match stream.read(&mut remaining).await {
Ok(0) => {
if full_request.ends_with(b"\r\n\r\n")
|| full_request.ends_with(b"\n\n")
|| total_read > 0
{
break;
}
return;
}
Ok(m) => {
reads += 1;
total_read += m;
full_request.extend_from_slice(&remaining[..m]);
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
break;
}
}
Err(_) => {
if total_read > 0 {
break;
}
return;
}
}
}
let _ =
handle_connect_from_buffer(stream, full_request, upstream_url, bypass_matcher).await;
return;
}
// Non-CONNECT: prepend consumed bytes and pass to hyper
let prepended_bytes = peek_buffer[..n].to_vec();
let prepended_reader = PrependReader {
prepended: prepended_bytes,
prepended_pos: 0,
inner: stream,
};
let io = TokioIo::new(prepended_reader);
let service =
service_fn(move |req| handle_request(req, upstream_url.clone(), bypass_matcher.clone()));
let _ = http1::Builder::new().serve_connection(io, service).await;
}
Err(_) => {}
}
}
pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
log::error!(
"Proxy worker starting, looking for config id: {}",
@@ -1052,145 +1133,11 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
// This ensures the process doesn't exit even if there are no active connections
loop {
match listener.accept().await {
Ok((mut stream, peer_addr)) => {
// Enable TCP_NODELAY to ensure small packets are sent immediately
// This is critical for CONNECT responses to be sent before tunneling begins
let _ = stream.set_nodelay(true);
log::error!("DEBUG: Accepted connection from {:?}", peer_addr);
Ok((stream, _peer_addr)) => {
let upstream = upstream_url.clone();
let matcher = bypass_matcher.clone();
tokio::task::spawn(async move {
// Wait for the stream to have readable data before attempting to read.
// This prevents read() from returning 0 on a fresh connection before
// the client's data arrives.
if stream.readable().await.is_err() {
return;
}
let mut peek_buffer = [0u8; 16];
match stream.read(&mut peek_buffer).await {
Ok(0) => {}
Ok(n) => {
// Check if this looks like a CONNECT request
// Be more lenient - check if the first bytes match "CONNECT" (case-insensitive)
let request_start_upper =
String::from_utf8_lossy(&peek_buffer[..n.min(7)]).to_uppercase();
let is_connect = request_start_upper.starts_with("CONNECT");
log::error!(
"DEBUG: Read {} bytes, starts with: {:?}, is_connect: {}",
n,
String::from_utf8_lossy(&peek_buffer[..n.min(20)]),
is_connect
);
if is_connect {
// Handle CONNECT request manually for tunneling
let mut full_request = Vec::with_capacity(4096);
full_request.extend_from_slice(&peek_buffer[..n]);
// Read the rest of the CONNECT request until we have the full headers
// CONNECT requests end with \r\n\r\n (or \n\n)
let mut remaining = [0u8; 4096];
let mut total_read = n;
let max_reads = 100; // Prevent infinite loop
let mut reads = 0;
loop {
if reads >= max_reads {
log::error!("DEBUG: Max reads reached, breaking");
break;
}
match stream.read(&mut remaining).await {
Ok(0) => {
// Connection closed, but we might have a complete request
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
break;
}
// If we have some data, try to process it anyway
if total_read > 0 {
break;
}
return; // No data at all
}
Ok(m) => {
reads += 1;
total_read += m;
full_request.extend_from_slice(&remaining[..m]);
// Check if we have complete headers
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
break;
}
// Also check if we have enough to parse (at least "CONNECT host:port HTTP/1.x")
if total_read >= 20 {
// Check if we have a newline that might indicate end of request line
if let Some(pos) = full_request.iter().position(|&b| b == b'\n') {
if pos < full_request.len() - 1 {
// We have at least the request line, check if we have headers
let request_str = String::from_utf8_lossy(&full_request);
if request_str.contains("\r\n\r\n") || request_str.contains("\n\n") {
break;
}
}
}
}
}
Err(e) => {
log::error!("DEBUG: Error reading CONNECT request: {:?}", e);
// If we have some data, try to process it
if total_read > 0 {
break;
}
return;
}
}
}
// Handle CONNECT manually
log::error!(
"DEBUG: Handling CONNECT manually for: {}",
String::from_utf8_lossy(&full_request[..full_request.len().min(200)])
);
if let Err(e) =
handle_connect_from_buffer(stream, full_request, upstream, matcher).await
{
log::error!("Error handling CONNECT request: {:?}", e);
} else {
log::error!("DEBUG: CONNECT handled successfully");
}
return;
}
// Not CONNECT (or partial read) - reconstruct stream with consumed bytes prepended
// This is critical: we MUST prepend any bytes we consumed, even if < 7 bytes
log::error!(
"DEBUG: Non-CONNECT request, first {} bytes: {:?}",
n,
String::from_utf8_lossy(&peek_buffer[..n.min(50)])
);
let prepended_bytes = peek_buffer[..n].to_vec();
let prepended_reader = PrependReader {
prepended: prepended_bytes,
prepended_pos: 0,
inner: stream,
};
let io = TokioIo::new(prepended_reader);
let service =
service_fn(move |req| handle_request(req, upstream.clone(), matcher.clone()));
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
log::error!("Error serving connection: {:?}", err);
}
}
Err(e) => {
log::error!("Error reading from connection: {:?}", e);
}
}
handle_proxy_connection(stream, upstream, matcher).await;
});
}
Err(e) => {
+48
View File
@@ -2361,6 +2361,54 @@ impl SyncEngine {
log::info!("No missing profiles found");
}
// Delete local synced profiles that have a remote tombstone (deleted on another device)
{
let profile_manager = ProfileManager::instance();
let local_synced: Vec<(String, Option<String>)> = profile_manager
.list_profiles()
.unwrap_or_default()
.iter()
.filter(|p| p.is_sync_enabled())
.map(|p| (p.id.to_string(), p.created_by_id.clone()))
.collect();
let team_prefix = if let Some(auth) = crate::cloud_auth::CLOUD_AUTH.get_user().await {
auth.user.team_id.map(|tid| format!("teams/{}/", tid))
} else {
None
};
for (pid, created_by_id) in &local_synced {
// Check personal tombstone
let personal_tombstone = format!("tombstones/profiles/{}.json", pid);
let has_personal_tombstone = matches!(
self.client.stat(&personal_tombstone).await,
Ok(stat) if stat.exists
);
// Check team tombstone
let has_team_tombstone = if let (Some(tp), Some(_)) = (&team_prefix, created_by_id) {
let team_tombstone = format!("{}tombstones/profiles/{}.json", tp, pid);
matches!(
self.client.stat(&team_tombstone).await,
Ok(stat) if stat.exists
)
} else {
false
};
if has_personal_tombstone || has_team_tombstone {
log::info!(
"Profile {} has remote tombstone, deleting locally (deleted on another device)",
pid
);
if let Err(e) = profile_manager.delete_profile_local_only(pid) {
log::warn!("Failed to delete tombstoned profile {}: {}", pid, e);
}
}
}
}
// Refresh metadata for local cross-OS profiles (propagate renames, tags, notes from originating device)
let profile_manager = ProfileManager::instance();
// Collect cross-OS profiles before async operations to avoid holding non-Send Result across await
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.17.2",
"version": "0.17.5",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+24 -11
View File
@@ -1648,14 +1648,18 @@ export function ProfilesDataTable({
// Cross-OS profiles: show OS icon when checkboxes aren't visible, show checkbox when they are
if (isCrossOs && !meta.showCheckboxes && !isSelected) {
const osName = profile.host_os
? getOSDisplayName(profile.host_os)
const resolvedOs =
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os;
const osName = resolvedOs
? getOSDisplayName(resolvedOs)
: "another OS";
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
const OsIcon =
profile.host_os === "macos"
resolvedOs === "macos"
? FaApple
: profile.host_os === "windows"
: resolvedOs === "windows"
? FaWindows
: FaLinux;
return (
@@ -1684,8 +1688,12 @@ export function ProfilesDataTable({
// Cross-OS profiles with checkboxes visible: show checkbox (selectable for bulk delete)
if (isCrossOs && (meta.showCheckboxes || isSelected)) {
const osName = profile.host_os
? getOSDisplayName(profile.host_os)
const resolvedOs =
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os;
const osName = resolvedOs
? getOSDisplayName(resolvedOs)
: "another OS";
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
return (
@@ -2017,7 +2025,7 @@ export function ProfilesDataTable({
);
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isCrossOsBlocked = isCrossOs;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
@@ -2078,7 +2086,7 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isCrossOsBlocked = isCrossOs;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
@@ -2107,7 +2115,7 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isCrossOsBlocked = isCrossOs;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
@@ -2134,7 +2142,7 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isCrossOsBlocked = isCrossOs;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
@@ -2534,7 +2542,12 @@ export function ProfilesDataTable({
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(row.original.host_os ?? ""),
os: getOSDisplayName(
row.original.host_os ||
row.original.camoufox_config?.os ||
row.original.wayfern_config?.os ||
"",
),
})
: undefined;
return (
+16 -4
View File
@@ -211,7 +211,7 @@ export function ProfileInfoDialog({
profile.release_type.slice(1);
const hasTags = profile.tags && profile.tags.length > 0;
const hasNote = !!profile.note;
const showCrossOs = !!(profile.host_os && isCrossOsProfile(profile));
const showCrossOs = isCrossOsProfile(profile);
type ActionItem = {
icon: React.ReactNode;
@@ -364,10 +364,22 @@ export function ProfileInfoDialog({
{t("profiles.ephemeralBadge")}
</Badge>
)}
{showCrossOs && profile.host_os && (
{showCrossOs && (
<Badge variant="outline" className="text-xs gap-1">
<OSIcon os={profile.host_os} />
{getOSDisplayName(profile.host_os)}
<OSIcon
os={
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
""
}
/>
{getOSDisplayName(
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
"",
)}
</Badge>
)}
</div>
+11 -8
View File
@@ -15,7 +15,7 @@ export function useBrowserState(
_isUpdating: (browser: string) => boolean,
launchingProfiles: Set<string>,
stoppingProfiles: Set<string>,
crossOsUnlocked = false,
_crossOsUnlocked = false,
) {
const [isClient, setIsClient] = useState(false);
@@ -53,7 +53,7 @@ export function useBrowserState(
(profile: BrowserProfile): boolean => {
if (!isClient) return false;
if (isCrossOsProfile(profile) && !crossOsUnlocked) return false;
if (isCrossOsProfile(profile)) return false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
@@ -81,7 +81,6 @@ export function useBrowserState(
isAnyInstanceRunning,
launchingProfiles,
stoppingProfiles,
crossOsUnlocked,
],
);
@@ -158,11 +157,16 @@ export function useBrowserState(
(profile: BrowserProfile): string => {
if (!isClient) return "Loading...";
if (isCrossOsProfile(profile) && profile.host_os) {
if (!crossOsUnlocked) {
const osName = getOSDisplayName(profile.host_os);
return `This profile was created on ${osName}. A paid subscription is required to launch cross-OS profiles.`;
if (isCrossOsProfile(profile)) {
const profileOs =
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os;
if (profileOs) {
const osName = getOSDisplayName(profileOs);
return `This profile was created on ${osName} and cannot be launched on a different operating system.`;
}
return "This profile was created on a different operating system and cannot be launched here.";
}
const isRunning = runningProfiles.has(profile.id);
@@ -197,7 +201,6 @@ export function useBrowserState(
canLaunchProfile,
launchingProfiles,
stoppingProfiles,
crossOsUnlocked,
],
);
+11 -3
View File
@@ -57,9 +57,17 @@ export const getCurrentOS = () => {
return "unknown";
};
export function isCrossOsProfile(profile: { host_os?: string }): boolean {
if (!profile.host_os) return false;
return profile.host_os !== getCurrentOS();
export function isCrossOsProfile(profile: {
host_os?: string;
camoufox_config?: { os?: string };
wayfern_config?: { os?: string };
}): boolean {
const profileOs =
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os;
if (!profileOs) return false;
return profileOs !== getCurrentOS();
}
export function getOSDisplayName(os: string): string {