Compare commits

...

20 Commits

Author SHA1 Message Date
zhom f7ae299771 chore: version bump 2025-12-01 01:47:32 +04:00
zhom c43f141907 refactor: better error handling 2025-12-01 01:46:28 +04:00
zhom cd33accb1a refactor: show tooltip for truncated text 2025-12-01 00:45:41 +04:00
zhom ca89b917f4 refactor: respect selected timeframe for top domains 2025-12-01 00:15:23 +04:00
zhom 6ad183ab89 style: profile table cleanup 2025-11-30 23:29:07 +04:00
zhom c83950bee7 chore: codegen 2025-11-30 22:43:10 +04:00
zhom 0047c80967 style: make the row chart shorter 2025-11-30 21:28:19 +04:00
zhom 3d7bd2b14c chore: version bump 2025-11-30 21:25:25 +04:00
zhom 8899e58987 chore: simplify tsconfig 2025-11-30 21:18:56 +04:00
zhom acf8651bd1 refactor: fix types after dependency upgrade 2025-11-30 21:16:26 +04:00
zhom ef534ee779 chore: update major dependencies 2025-11-30 21:06:09 +04:00
zhom 75bb10cf61 chore: remove ipecho from domain checkers 2025-11-30 21:03:31 +04:00
zhom 6f9e0de633 chore: update dependencies 2025-11-30 20:59:19 +04:00
zhom 39c2a9f6f0 refactor: disable quit confirmations in browser 2025-11-30 20:59:04 +04:00
zhom 4b6f08fca3 refactor: disable more update-related settings 2025-11-30 20:44:59 +04:00
zhom 24eff75d4e chore: cleanup logs 2025-11-30 20:42:06 +04:00
zhom 11869855e9 build: make permissions more explicit 2025-11-30 20:40:34 +04:00
zhom 0d1f1f1497 refactor: suppress first-run warnings 2025-11-30 20:40:10 +04:00
zhom e8026d817f refactor: clean up old binary after installation 2025-11-30 20:39:34 +04:00
zhom d1ca4273de chore: check tag name instead of ref 2025-11-30 20:08:25 +04:00
25 changed files with 1429 additions and 1181 deletions
@@ -11,7 +11,7 @@ permissions:
jobs:
generate-release-notes:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') && !github.event.release.prerelease
if: startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease
steps:
- name: Checkout repository
@@ -23,7 +23,7 @@ jobs:
id: get-previous-tag
run: |
# Get the previous release tag (excluding the current one)
CURRENT_TAG="${{ github.ref_name }}"
CURRENT_TAG="${{ github.event.release.tag_name }}"
PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "$CURRENT_TAG" | head -n 1)
if [ -z "$PREVIOUS_TAG" ]; then
+9
View File
@@ -54,6 +54,7 @@
"esac",
"esbuild",
"etree",
"firstrun",
"flate",
"frontmost",
"geoip",
@@ -66,10 +67,12 @@
"hkcu",
"hooksconfig",
"hookspath",
"Hoverable",
"icns",
"idlelib",
"idletime",
"idna",
"infobars",
"Inno",
"kdeglobals",
"keras",
@@ -77,6 +80,7 @@
"killall",
"Kolkata",
"kreadconfig",
"langpack",
"launchservices",
"letterboxing",
"libatk",
@@ -108,6 +112,7 @@
"nobrowse",
"noconfirm",
"nodecar",
"NODELAY",
"nodemon",
"norestart",
"NSIS",
@@ -139,6 +144,7 @@
"pyoxidizer",
"pytest",
"pyyaml",
"reportingpolicy",
"reqwest",
"ridedott",
"rlib",
@@ -149,6 +155,7 @@
"screeninfo",
"selectables",
"serde",
"sessionstore",
"setpriority",
"setsid",
"SETTINGCHANGE",
@@ -184,6 +191,7 @@
"Torbrowser",
"tqdm",
"trackingprotection",
"trailhead",
"turbopack",
"turtledemo",
"udeps",
@@ -192,6 +200,7 @@
"unrs",
"urlencoding",
"urllib",
"utoipa",
"venv",
"vercel",
"VERYSILENT",
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./dist/types/routes.d.ts" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+4 -4
View File
@@ -21,15 +21,15 @@
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^24.10.0",
"@types/node": "^24.10.1",
"commander": "^14.0.2",
"donutbrowser-camoufox-js": "^0.7.0",
"dotenv": "^17.2.3",
"fingerprint-generator": "^2.1.76",
"fingerprint-generator": "^2.1.77",
"get-port": "^7.1.0",
"nodemon": "^3.1.11",
"playwright-core": "^1.56.1",
"proxy-chain": "^2.5.9",
"playwright-core": "^1.57.0",
"proxy-chain": "^2.6.0",
"tmp": "^0.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
+11 -11
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.13.0",
"version": "0.13.2",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
@@ -41,7 +41,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-deep-link": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "~2.4.4",
@@ -51,17 +51,17 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.2",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"lucide-react": "^0.555.0",
"motion": "^12.23.24",
"next": "^15.5.6",
"next": "^16.0.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"recharts": "2.15.4",
"recharts": "3.5.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
@@ -69,14 +69,14 @@
"devDependencies": {
"@biomejs/biome": "2.2.3",
"@tailwindcss/postcss": "^4.1.17",
"@tauri-apps/cli": "^2.9.4",
"@tauri-apps/cli": "^2.9.5",
"@types/color": "^4.2.0",
"@types/node": "^24.10.0",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"husky": "^9.1.7",
"lint-staged": "^16.2.6",
"lint-staged": "^16.2.7",
"tailwindcss": "^4.1.17",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
+971 -953
View File
File diff suppressed because it is too large Load Diff
+54 -1
View File
@@ -1293,7 +1293,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.13.0"
version = "0.13.2"
dependencies = [
"aes-gcm",
"argon2",
@@ -1306,6 +1306,7 @@ dependencies = [
"clap",
"core-foundation 0.10.1",
"directories",
"env_logger",
"flate2",
"futures-util",
"http-body-util",
@@ -1458,6 +1459,19 @@ dependencies = [
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -2524,6 +2538,30 @@ dependencies = [
"system-deps",
]
[[package]]
name = "jiff"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -3728,6 +3766,21 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.13.0"
version = "0.13.2"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -39,6 +39,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-log = "2"
log = "0.4"
env_logger = "0.11"
directories = "6"
reqwest = { version = "0.12", features = ["json", "stream", "socks"] }
+9 -1
View File
@@ -5,7 +5,15 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-emit",
"core:event:allow-emit-to",
"core:event:allow-unlisten",
"core:image:default",
"core:menu:default",
"core:path:default",
"core:tray:default",
"core:webview:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
-1
View File
@@ -564,7 +564,6 @@ impl ApiClient {
let cached_data: CachedGithubData = serde_json::from_str(&content).ok()?;
// Always use cached GitHub releases - cache never expires, only gets updated with new versions
log::info!("Using cached GitHub releases for {browser}");
Some(cached_data.releases)
}
+16
View File
@@ -999,6 +999,22 @@ impl AppAutoUpdater {
// Clean up backup after successful installation
let _ = fs::remove_dir_all(&backup_path);
// Clean up old "Donut Browser.app" if it exists (from before the project rename)
if let Some(parent_dir) = current_app_path.parent() {
let old_app_path = parent_dir.join("Donut Browser.app");
if old_app_path.exists() && old_app_path != current_app_path {
log::info!(
"Removing old 'Donut Browser.app' from: {}",
old_app_path.display()
);
if let Err(e) = fs::remove_dir_all(&old_app_path) {
log::warn!("Warning: Failed to remove old 'Donut Browser.app': {e}");
} else {
log::info!("Successfully removed old 'Donut Browser.app'");
}
}
}
Ok(())
}
+6
View File
@@ -82,6 +82,12 @@ fn build_proxy_url(
#[tokio::main(flavor = "multi_thread")]
async fn main() {
// Initialize logger to write to stderr (which will be redirected to file)
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Debug)
.format_timestamp_millis()
.init();
// Set up panic handler to log panics before process exits
std::panic::set_hook(Box::new(|panic_info| {
log::error!("PANIC in proxy worker: {:?}", panic_info);
+14 -25
View File
@@ -624,22 +624,9 @@ impl Browser for FirefoxBrowser {
args.push("--headless".to_string());
}
// Use -no-remote for browsers that require it for security (Mullvad, Tor) or when remote debugging
match self.browser_type {
BrowserType::MullvadBrowser | BrowserType::TorBrowser => {
args.push("-no-remote".to_string());
}
BrowserType::Firefox
| BrowserType::FirefoxDeveloper
| BrowserType::Zen
| BrowserType::Camoufox => {
// Use -no-remote when remote debugging to avoid conflicts
if remote_debugging_port.is_some() {
args.push("-no-remote".to_string());
}
// Don't use -no-remote for normal launches so we can communicate with existing instances
}
_ => {}
// Use -no-remote when remote debugging to avoid conflicts with existing instances
if remote_debugging_port.is_some() {
args.push("-no-remote".to_string());
}
// Firefox-based browsers use profile directory and user.js for proxy configuration
@@ -737,6 +724,12 @@ impl Browser for ChromiumBrowser {
"--disable-background-timer-throttling".to_string(),
"--crash-server-url=".to_string(),
"--disable-updater".to_string(),
// Disable quit confirmation and session restore prompts
"--disable-session-crashed-bubble".to_string(),
"--hide-crash-restore-bubble".to_string(),
"--disable-infobars".to_string(),
// Disable QUIC/HTTP3 to ensure traffic goes through HTTP proxy
"--disable-quic".to_string(),
];
// Add remote debugging if requested
@@ -1096,30 +1089,26 @@ mod tests {
"Firefox should include debugging port"
);
// Test Mullvad Browser (should always use -no-remote)
// Test Mullvad Browser (no special flags without remote debugging)
let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser);
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Mullvad Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
// Test Tor Browser (should always use -no-remote)
// Test Tor Browser (no special flags without remote debugging)
let browser = FirefoxBrowser::new(BrowserType::TorBrowser);
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Tor Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
// Test Zen Browser (should not use -no-remote for normal launch)
// Test Zen Browser (no special flags without remote debugging)
let browser = FirefoxBrowser::new(BrowserType::Zen);
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Zen Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
assert!(
!args.contains(&"-no-remote".to_string()),
"Zen Browser should not use -no-remote for normal launch"
);
// Test headless mode
let args = browser
+114 -113
View File
@@ -1071,7 +1071,7 @@ impl ProfileManager {
fn get_common_firefox_preferences(&self) -> Vec<String> {
vec![
// Disable default browser updates
// Disable default browser check
"user_pref(\"browser.shell.checkDefaultBrowser\", false);".to_string(),
"user_pref(\"browser.shell.skipDefaultBrowserCheckOnFirstRun\", true);".to_string(),
"user_pref(\"browser.preferences.moreFromMozilla\", false);".to_string(),
@@ -1086,27 +1086,58 @@ impl ProfileManager {
// Keep extension updates enabled
"user_pref(\"extensions.update.enabled\", true);".to_string(),
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
// Completely disable browser update checking
"user_pref(\"app.update.enabled\", false);".to_string(),
"user_pref(\"app.update.staging.enabled\", false);".to_string(),
"user_pref(\"app.update.timerFirstInterval\", -1);".to_string(),
"user_pref(\"app.update.download.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.elevate.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.disabledForTesting\", true);".to_string(),
"user_pref(\"app.update.auto\", false);".to_string(),
"user_pref(\"app.update.mode\", 0);".to_string(),
"user_pref(\"app.update.promptWaitTime\", -1);".to_string(),
"user_pref(\"app.update.service.enabled\", false);".to_string(),
"user_pref(\"app.update.staging.enabled\", false);".to_string(),
"user_pref(\"app.update.silent\", true);".to_string(),
"user_pref(\"app.update.disabledForTesting\", true);".to_string(),
// Prevent update URL access entirely
"user_pref(\"app.update.url\", \"\");".to_string(),
"user_pref(\"app.update.url.manual\", \"\");".to_string(),
"user_pref(\"app.update.url.details\", \"\");".to_string(),
// Disable update timing/scheduling
"user_pref(\"app.update.timerFirstInterval\", 999999999);".to_string(),
"user_pref(\"app.update.interval\", 999999999);".to_string(),
"user_pref(\"app.update.background.interval\", 999999999);".to_string(),
"user_pref(\"app.update.idletime\", 999999999);".to_string(),
"user_pref(\"app.update.promptWaitTime\", 999999999);".to_string(),
// Disable update attempts
"user_pref(\"app.update.download.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.elevate.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.checkInstallTime\", false);".to_string(),
"user_pref(\"app.update.interval\", -1);".to_string(),
"user_pref(\"app.update.background.interval\", -1);".to_string(),
"user_pref(\"app.update.idletime\", -1);".to_string(),
// Suppress additional update UI/prompts
// Suppress update UI/prompts/notifications
"user_pref(\"app.update.doorhanger\", false);".to_string(),
"user_pref(\"app.update.badge\", false);".to_string(),
"user_pref(\"app.update.notifyDuringDownload\", false);".to_string(),
"user_pref(\"app.update.background.scheduling.enabled\", false);".to_string(),
"user_pref(\"app.update.background.enabled\", false);".to_string(),
// Disable BITS (Windows Background Intelligent Transfer Service) updates
"user_pref(\"app.update.BITS.enabled\", false);".to_string(),
// Disable language pack updates
"user_pref(\"app.update.langpack.enabled\", false);".to_string(),
// Suppress upgrade dialogs on startup
"user_pref(\"browser.startup.upgradeDialog.enabled\", false);".to_string(),
// Disable update ping telemetry
"user_pref(\"toolkit.telemetry.updatePing.enabled\", false);".to_string(),
// Zen browser specific - disable welcome screen and updates
"user_pref(\"zen.welcome-screen.seen\", true);".to_string(),
"user_pref(\"zen.updates.enabled\", false);".to_string(),
"user_pref(\"zen.updates.check-for-updates\", false);".to_string(),
// Additional first-run suppressions
"user_pref(\"app.normandy.first_run\", false);".to_string(),
"user_pref(\"trailhead.firstrun.didSeeAboutWelcome\", true);".to_string(),
"user_pref(\"datareporting.policy.dataSubmissionPolicyBypassNotification\", true);"
.to_string(),
"user_pref(\"toolkit.telemetry.reportingpolicy.firstRun\", false);".to_string(),
// Disable quit confirmation dialogs
"user_pref(\"browser.warnOnQuit\", false);".to_string(),
"user_pref(\"browser.showQuitWarning\", false);".to_string(),
"user_pref(\"browser.tabs.warnOnClose\", false);".to_string(),
"user_pref(\"browser.tabs.warnOnCloseOtherTabs\", false);".to_string(),
"user_pref(\"browser.sessionstore.warnOnQuit\", false);".to_string(),
]
}
@@ -1128,114 +1159,76 @@ impl ProfileManager {
let mut preferences = Vec::new();
// Get the UUID directory (parent of profile data directory)
let uuid_dir = profile_data_path
.parent()
.ok_or("Invalid profile path - cannot find UUID directory")?;
// Add common Firefox preferences (like disabling default browser check)
preferences.extend(self.get_common_firefox_preferences());
// Use embedded PAC template instead of reading from file
const PAC_TEMPLATE: &str = r#"function FindProxyForURL(url, host) {
return "{{proxy_url}}";
}"#;
// Determine which proxy settings to use
let effective_proxy = internal_proxy.unwrap_or(proxy);
let proxy_host = &effective_proxy.host;
let proxy_port = effective_proxy.port;
// Format proxy URL based on type and whether we have an internal proxy
let proxy_url = if let Some(internal) = internal_proxy {
// Use internal proxy (local proxy) as the primary proxy
// This is the local proxy that forwards to the upstream proxy
log::info!(
"Applying local proxy settings to Firefox profile: {}:{}",
internal.host,
internal.port
);
format!("HTTP {}:{}", internal.host, internal.port)
} else {
// Use user-configured proxy directly (upstream proxy)
log::info!(
"Applying upstream proxy settings to Firefox profile: {}:{} ({})",
proxy.host,
proxy.port,
proxy.proxy_type
);
match proxy.proxy_type.as_str() {
"http" => format!("HTTP {}:{}", proxy.host, proxy.port),
"https" => format!("HTTPS {}:{}", proxy.host, proxy.port),
"socks4" => format!("SOCKS4 {}:{}", proxy.host, proxy.port),
"socks5" => format!("SOCKS5 {}:{}", proxy.host, proxy.port),
_ => return Err(format!("Unsupported proxy type: {}", proxy.proxy_type).into()),
}
};
// Check if this is a SOCKS proxy (only possible when using upstream directly)
let is_socks =
internal_proxy.is_none() && (proxy.proxy_type == "socks4" || proxy.proxy_type == "socks5");
// Replace placeholders in PAC file
let pac_content = PAC_TEMPLATE
.replace("{{proxy_url}}", &proxy_url)
.replace("{{proxy_credentials}}", ""); // Credentials are now handled by the PAC file
// Save PAC file in UUID directory
let pac_path = uuid_dir.join("proxy.pac");
log::info!(
"Creating PAC file at: {} with proxy: {}",
pac_path.display(),
proxy_url
);
fs::write(&pac_path, &pac_content)?;
log::info!(
"Created PAC file at: {} with content: {}",
pac_path.display(),
pac_content
"Applying manual proxy settings to Firefox profile: {}:{} (is_internal: {}, is_socks: {})",
proxy_host,
proxy_port,
internal_proxy.is_some(),
is_socks
);
// Configure Firefox to use the PAC file
// Convert path to absolute and properly format for file:// URL
let pac_path_absolute = pac_path.canonicalize().unwrap_or_else(|_| pac_path.clone());
let pac_url = if cfg!(windows) {
// Windows: file:///C:/path/to/file.pac
format!(
"file:///{}",
pac_path_absolute.to_string_lossy().replace('\\', "/")
)
// Use MANUAL proxy configuration (type 1) instead of PAC file (type 2)
// PAC files with file:// URLs are blocked by privacy-focused browsers like Zen and Mullvad
// Manual proxy configuration works reliably across all Firefox variants
preferences.push("user_pref(\"network.proxy.type\", 1);".to_string());
if is_socks {
// SOCKS proxy configuration
preferences.extend([
format!("user_pref(\"network.proxy.socks\", \"{}\");", proxy_host),
format!("user_pref(\"network.proxy.socks_port\", {});", proxy_port),
format!(
"user_pref(\"network.proxy.socks_version\", {});",
if proxy.proxy_type == "socks5" { 5 } else { 4 }
),
"user_pref(\"network.proxy.http\", \"\");".to_string(),
"user_pref(\"network.proxy.http_port\", 0);".to_string(),
"user_pref(\"network.proxy.ssl\", \"\");".to_string(),
"user_pref(\"network.proxy.ssl_port\", 0);".to_string(),
]);
} else {
// Unix/macOS: file:///absolute/path/to/file.pac (three slashes for absolute path)
format!("file://{}", pac_path_absolute.to_string_lossy())
};
log::info!("PAC file path (absolute): {}", pac_path_absolute.display());
log::info!("PAC file URL for Firefox: {}", pac_url);
// HTTP/HTTPS proxy configuration (including our internal local proxy)
preferences.extend([
format!("user_pref(\"network.proxy.http\", \"{}\");", proxy_host),
format!("user_pref(\"network.proxy.http_port\", {});", proxy_port),
format!("user_pref(\"network.proxy.ssl\", \"{}\");", proxy_host),
format!("user_pref(\"network.proxy.ssl_port\", {});", proxy_port),
format!("user_pref(\"network.proxy.ftp\", \"{}\");", proxy_host),
format!("user_pref(\"network.proxy.ftp_port\", {});", proxy_port),
"user_pref(\"network.proxy.socks\", \"\");".to_string(),
"user_pref(\"network.proxy.socks_port\", 0);".to_string(),
]);
}
// Common proxy settings - keep it simple like proxy-chain expected
preferences.extend([
"user_pref(\"network.proxy.type\", 2);".to_string(),
format!(
"user_pref(\"network.proxy.autoconfig_url\", \"{}\");",
pac_url
),
"user_pref(\"network.proxy.failover_direct\", false);".to_string(),
"user_pref(\"network.proxy.socks_remote_dns\", true);".to_string(),
"user_pref(\"network.proxy.no_proxies_on\", \"\");".to_string(),
"user_pref(\"signon.autologin.proxy\", true);".to_string(),
"user_pref(\"network.proxy.share_proxy_settings\", false);".to_string(),
"user_pref(\"network.automatic-ntlm-auth.allow-proxies\", false);".to_string(),
"user_pref(\"network.auth-use-sspi\", false);".to_string(),
"user_pref(\"network.proxy.autoconfig_url\", \"\");".to_string(),
// Disable QUIC/HTTP3 - it bypasses HTTP proxy
"user_pref(\"network.http.http3.enable\", false);".to_string(),
"user_pref(\"network.http.http3.enabled\", false);".to_string(),
]);
// Write settings to user.js file
let user_js_content = preferences.join("\n");
fs::write(user_js_path, &user_js_content)?;
log::info!("Updated user.js with proxy settings. PAC URL: {}", pac_url);
if let Some(internal) = internal_proxy {
log::info!(
"Firefox will use LOCAL proxy: {}:{} (which forwards to upstream)",
internal.host,
internal.port
);
} else {
log::info!(
"Firefox will use UPSTREAM proxy directly: {}:{}",
proxy.host,
proxy.port
);
}
log::info!(
"Updated user.js with manual proxy settings: {}:{}",
proxy_host,
proxy_port
);
Ok(())
}
@@ -1397,20 +1390,28 @@ mod tests {
assert!(user_js_path.exists(), "user.js should be created");
let content = fs::read_to_string(&user_js_path).expect("Should read user.js");
// Check for manual proxy configuration (type 1) instead of PAC (type 2)
// Manual proxy is used because PAC file:// URLs are blocked by privacy browsers like Zen
assert!(
content.contains("network.proxy.type"),
"Should contain proxy type setting"
content.contains("network.proxy.type\", 1"),
"Should set proxy type to 1 (manual)"
);
assert!(content.contains("2"), "Should set proxy type to 2 (PAC)");
// Check that PAC file was created
let pac_path = uuid_dir.join("proxy.pac");
assert!(pac_path.exists(), "proxy.pac should be created");
let pac_content = fs::read_to_string(&pac_path).expect("Should read proxy.pac");
assert!(
pac_content.contains("FindProxyForURL"),
"PAC file should contain FindProxyForURL function"
content.contains("network.proxy.http\", \"proxy.example.com\""),
"Should set HTTP proxy host"
);
assert!(
content.contains("network.proxy.http_port\", 8080"),
"Should set HTTP proxy port"
);
assert!(
content.contains("network.proxy.ssl\", \"proxy.example.com\""),
"Should set SSL proxy host"
);
assert!(
content.contains("network.proxy.ssl_port\", 8080"),
"Should set SSL proxy port"
);
}
}
-1
View File
@@ -491,7 +491,6 @@ impl ProxyManager {
"https://ipinfo.io/ip",
"https://icanhazip.com",
"https://ifconfig.co/ip",
"https://ipecho.net/plain",
];
// Create HTTP client with proxy
+6 -12
View File
@@ -59,18 +59,12 @@ pub async fn start_proxy_process_with_profile(
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
#[cfg(debug_assertions)]
{
let log_path = std::path::PathBuf::from("/tmp").join(format!("donut-proxy-{}.log", id));
if let Ok(file) = std::fs::File::create(&log_path) {
log::error!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
cmd.stderr(Stdio::null());
}
}
#[cfg(not(debug_assertions))]
{
// Always log to file for diagnostics (both debug and release builds)
let log_path = std::path::PathBuf::from("/tmp").join(format!("donut-proxy-{}.log", id));
if let Ok(file) = std::fs::File::create(&log_path) {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
cmd.stderr(Stdio::null());
}
+33 -14
View File
@@ -367,6 +367,13 @@ async fn handle_http(
// This is faster and more reliable than trying to use hyper-proxy with version conflicts
use reqwest::Client;
log::error!(
"DEBUG: Handling HTTP request: {} {} (host: {:?})",
req.method(),
req.uri(),
req.uri().host()
);
// Extract domain for traffic tracking
let domain = req
.uri()
@@ -609,7 +616,12 @@ 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, _)) => {
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);
let upstream = upstream_url.clone();
tokio::task::spawn(async move {
@@ -617,9 +629,13 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
// CONNECT requests need special handling for tunneling
let mut peek_buffer = [0u8; 8];
match stream.read(&mut peek_buffer).await {
Ok(n) if n >= 7 => {
Ok(0) => {
log::error!("DEBUG: Connection closed immediately (0 bytes read)");
}
Ok(n) => {
let request_start = String::from_utf8_lossy(&peek_buffer[..n.min(7)]);
if request_start.starts_with("CONNECT") {
log::error!("DEBUG: Read {} bytes, starts with: {:?}", n, request_start);
if n >= 7 && request_start.starts_with("CONNECT") {
// Handle CONNECT request manually for tunneling
let mut full_request = Vec::with_capacity(4096);
full_request.extend_from_slice(&peek_buffer[..n]);
@@ -651,7 +667,14 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
}
return;
}
// Not CONNECT - reconstruct stream with consumed bytes prepended
// 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])
);
let prepended_bytes = peek_buffer[..n].to_vec();
let prepended_reader = PrependReader {
prepended: prepended_bytes,
@@ -664,17 +687,10 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
log::error!("Error serving connection: {:?}", err);
}
return;
}
_ => {}
}
// For non-CONNECT requests, use hyper's HTTP handling
let io = TokioIo::new(stream);
let service = service_fn(move |req| handle_request(req, upstream.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);
}
}
});
}
@@ -807,6 +823,9 @@ async fn handle_connect_from_buffer(
}
};
// Enable TCP_NODELAY on target stream for immediate data transfer
let _ = target_stream.set_nodelay(true);
// Send 200 Connection Established response to client
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
client_stream
+76 -4
View File
@@ -17,6 +17,19 @@ pub struct BandwidthDataPoint {
pub bytes_received: u64,
}
/// Individual domain access data point for time-series tracking
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainAccessPoint {
/// Unix timestamp in seconds
pub timestamp: u64,
/// Domain name
pub domain: String,
/// Bytes sent in this request
pub bytes_sent: u64,
/// Bytes received in this request
pub bytes_received: u64,
}
/// Domain access information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainAccess {
@@ -78,9 +91,12 @@ pub struct TrafficStats {
/// Bandwidth data points (time-series, 1 point per second, stored indefinitely)
#[serde(default)]
pub bandwidth_history: Vec<BandwidthDataPoint>,
/// Domain access statistics
/// Domain access statistics (aggregated all-time)
#[serde(default)]
pub domains: HashMap<String, DomainAccess>,
/// Domain access history (time-series for filtering by period)
#[serde(default)]
pub domain_access_history: Vec<DomainAccessPoint>,
/// Unique IPs accessed
#[serde(default)]
pub unique_ips: Vec<String>,
@@ -99,6 +115,7 @@ impl TrafficStats {
total_requests: 0,
bandwidth_history: Vec::new(),
domains: HashMap::new(),
domain_access_history: Vec::new(),
unique_ips: Vec::new(),
}
}
@@ -163,6 +180,7 @@ impl TrafficStats {
let now = current_timestamp();
self.total_requests += 1;
// Update aggregated domain stats
let entry = self
.domains
.entry(domain.to_string())
@@ -179,6 +197,14 @@ impl TrafficStats {
entry.bytes_sent += bytes_sent;
entry.bytes_received += bytes_received;
entry.last_access = now;
// Add to domain access history for time-period filtering
self.domain_access_history.push(DomainAccessPoint {
timestamp: now,
domain: domain.to_string(),
bytes_sent,
bytes_received,
});
}
/// Record an IP address access
@@ -362,6 +388,14 @@ fn merge_traffic_stats(dest: &mut TrafficStats, src: &TrafficStats) {
entry.last_access = entry.last_access.max(access.last_access);
}
// Merge domain access history
let mut combined_domain_history: Vec<DomainAccessPoint> = dest.domain_access_history.clone();
for point in &src.domain_access_history {
combined_domain_history.push(point.clone());
}
combined_domain_history.sort_by_key(|p| p.timestamp);
dest.domain_access_history = combined_domain_history;
// Merge unique IPs
for ip in &src.unique_ips {
if !dest.unique_ips.contains(ip) {
@@ -557,7 +591,9 @@ pub struct FilteredTrafficStats {
/// Period stats: bytes sent/received within the requested period
pub period_bytes_sent: u64,
pub period_bytes_received: u64,
/// Domain access statistics (always full, as it's already aggregated)
/// Period requests within the requested period
pub period_requests: u64,
/// Domain access statistics filtered to requested time period
pub domains: HashMap<String, DomainAccess>,
/// Unique IPs accessed
pub unique_ips: Vec<String>,
@@ -586,10 +622,45 @@ pub fn get_traffic_stats_for_period(
.cloned()
.collect();
// Calculate period totals
// Calculate period totals for bandwidth
let period_bytes_sent: u64 = filtered_history.iter().map(|dp| dp.bytes_sent).sum();
let period_bytes_received: u64 = filtered_history.iter().map(|dp| dp.bytes_received).sum();
// Filter and aggregate domain stats for the period
let mut filtered_domains: HashMap<String, DomainAccess> = HashMap::new();
let mut period_requests: u64 = 0;
for access in stats
.domain_access_history
.iter()
.filter(|a| a.timestamp >= cutoff)
{
period_requests += 1;
let entry = filtered_domains
.entry(access.domain.clone())
.or_insert(DomainAccess {
domain: access.domain.clone(),
request_count: 0,
bytes_sent: 0,
bytes_received: 0,
first_access: access.timestamp,
last_access: access.timestamp,
});
entry.request_count += 1;
entry.bytes_sent += access.bytes_sent;
entry.bytes_received += access.bytes_received;
entry.first_access = entry.first_access.min(access.timestamp);
entry.last_access = entry.last_access.max(access.timestamp);
}
// If no domain_access_history exists (old data), fall back to all-time domains
let domains = if stats.domain_access_history.is_empty() {
stats.domains
} else {
filtered_domains
};
Some(FilteredTrafficStats {
profile_id: stats.profile_id,
session_start: stats.session_start,
@@ -600,7 +671,8 @@ pub fn get_traffic_stats_for_period(
bandwidth_history: filtered_history,
period_bytes_sent,
period_bytes_received,
domains: stats.domains,
period_requests,
domains,
unique_ips: stats.unique_ips,
})
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.13.0",
"version": "0.13.2",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+4 -2
View File
@@ -69,11 +69,11 @@ export function BandwidthMiniChart({
type="button"
onClick={onClick}
className={cn(
"relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors min-w-[130px] border-none bg-transparent",
"relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors min-w-[120px] border-none bg-transparent",
className,
)}
>
<div className="flex-1 h-3">
<div className="flex-1 h-3 pointer-events-none">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
@@ -106,6 +106,8 @@ export function BandwidthMiniChart({
strokeWidth={1}
fill="url(#bandwidthGradient)"
isAnimationActive={false}
dot={false}
activeDot={false}
/>
</AreaChart>
</ResponsiveContainer>
+9 -6
View File
@@ -331,7 +331,9 @@ const TagsCell = React.memo<{
ref={containerRef as unknown as React.RefObject<HTMLButtonElement>}
className={cn(
"flex overflow-hidden gap-1 items-center px-2 py-1 h-6 w-full bg-transparent rounded border-none cursor-pointer",
isDisabled ? "opacity-60" : "cursor-pointer hover:bg-accent/50",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
)}
onClick={() => {
if (!isDisabled) setOpenTagsEditorFor(profile.id);
@@ -354,7 +356,7 @@ const TagsCell = React.memo<{
);
return (
<div className="w-48 h-6 cursor-pointer">
<div className="w-40 h-6 cursor-pointer">
<Tooltip>
<TooltipTrigger asChild>{ButtonContent}</TooltipTrigger>
{hiddenCount > 0 && (
@@ -380,13 +382,13 @@ const TagsCell = React.memo<{
return (
<div
className={cn(
"w-48 h-6 relative",
"w-40 h-6 relative",
isDisabled && "opacity-60 pointer-events-none",
)}
>
<div
ref={editorRef}
className="absolute top-0 left-0 z-50 w-48 min-h-6 bg-popover rounded-md shadow-md"
className="absolute top-0 left-0 z-50 w-40 min-h-6 bg-popover rounded-md shadow-md"
>
<MultipleSelector
value={valueOptions}
@@ -1451,8 +1453,9 @@ export function ProfilesDataTable({
size="sm"
disabled={!canLaunch || isLaunching || isStopping}
className={cn(
"cursor-pointer min-w-[70px] h-7",
!canLaunch && "opacity-50",
"min-w-[70px] h-7",
!canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer",
)}
onClick={() =>
isRunning
+66 -18
View File
@@ -2,7 +2,6 @@
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import type { TooltipProps } from "recharts";
import {
Area,
AreaChart,
@@ -12,10 +11,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import type {
NameType,
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import {
Dialog,
DialogContent,
@@ -30,6 +26,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
TooltipContent,
TooltipTrigger,
Tooltip as UITooltip,
} from "@/components/ui/tooltip";
import type { FilteredTrafficStats } from "@/types";
type TimePeriod =
@@ -94,6 +95,53 @@ function getSecondsForPeriod(period: TimePeriod): number {
}
}
const TruncatedDomain = React.memo<{ domain: string }>(({ domain }) => {
const ref = React.useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);
const checkTruncation = React.useCallback(() => {
if (ref.current) {
setIsTruncated(ref.current.scrollWidth > ref.current.clientWidth);
}
}, []);
React.useLayoutEffect(() => {
checkTruncation();
});
React.useEffect(() => {
const resizeObserver = new ResizeObserver(checkTruncation);
if (ref.current) {
resizeObserver.observe(ref.current);
}
return () => {
resizeObserver.disconnect();
};
}, [checkTruncation]);
const content = (
<span ref={ref} className="truncate max-w-[200px] block">
{domain}
</span>
);
if (!isTruncated) {
return content;
}
return (
<UITooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>
<p>{domain}</p>
</TooltipContent>
</UITooltip>
);
});
TruncatedDomain.displayName = "TruncatedDomain";
export function TrafficDetailsDialog({
isOpen,
onClose,
@@ -140,7 +188,7 @@ export function TrafficDetailsDialog({
// Tooltip render function
const renderTooltip = React.useCallback(
(props: TooltipProps<ValueType, NameType>) => {
(props: TooltipContentProps<number, string>) => {
const { active, payload, label } = props;
if (!active || !payload?.length) return null;
@@ -356,9 +404,11 @@ export function TrafficDetailsDialog({
</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Requests</p>
<p className="text-xs text-muted-foreground">
Requests ({timePeriod === "all" ? "total" : timePeriod})
</p>
<p className="text-lg font-semibold">
{(stats?.total_requests || 0).toLocaleString()}
{(stats?.period_requests || 0).toLocaleString()}
</p>
</div>
</div>
@@ -366,14 +416,14 @@ export function TrafficDetailsDialog({
{/* Total Stats (smaller, under period stats) */}
<div className="flex items-center gap-6 text-sm text-muted-foreground border-t pt-4">
<div>
<span className="font-medium">Total:</span>{" "}
<span className="font-medium">All-time traffic:</span>{" "}
{formatBytes(
(stats?.total_bytes_sent || 0) +
(stats?.total_bytes_received || 0),
)}
</div>
<div>
<span className="font-medium">Requests:</span>{" "}
<span className="font-medium">All-time requests:</span>{" "}
{stats?.total_requests?.toLocaleString() || 0}
</div>
</div>
@@ -389,7 +439,8 @@ export function TrafficDetailsDialog({
{topDomainsByTraffic.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2">
Top Domains by Traffic
Top Domains by Traffic (
{timePeriod === "all" ? "all time" : timePeriod})
</h3>
<div className="border rounded-md">
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
@@ -408,9 +459,7 @@ export function TrafficDetailsDialog({
<span className="text-xs text-muted-foreground w-4 shrink-0">
{index + 1}
</span>
<span className="truncate" title={domain.domain}>
{domain.domain}
</span>
<TruncatedDomain domain={domain.domain} />
</div>
<span className="text-right text-muted-foreground">
{domain.request_count.toLocaleString()}
@@ -432,7 +481,8 @@ export function TrafficDetailsDialog({
{topDomainsByRequests.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2">
Top Domains by Requests
Top Domains by Requests (
{timePeriod === "all" ? "all time" : timePeriod})
</h3>
<div className="border rounded-md">
<div className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
@@ -450,9 +500,7 @@ export function TrafficDetailsDialog({
<span className="text-xs text-muted-foreground w-4 shrink-0">
{index + 1}
</span>
<span className="truncate" title={domain.domain}>
{domain.domain}
</span>
<TruncatedDomain domain={domain.domain} />
</div>
<span className="text-right text-muted-foreground">
{domain.request_count.toLocaleString()}
+16 -8
View File
@@ -2,6 +2,12 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type {
Props as DefaultLegendContentProps,
LegendPayload,
} from "recharts/types/component/DefaultLegendContent";
import type { Payload } from "recharts/types/component/DefaultTooltipContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { cn } from "@/lib/utils";
@@ -105,13 +111,15 @@ const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
TooltipContentProps<number, string> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
labelClassName?: string;
color?: string;
}
>(
(
@@ -187,15 +195,15 @@ const ChartTooltipContent = React.forwardRef<
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
.filter((item: Payload<number, string>) => item.type !== "none")
.map((item: Payload<number, string>, index: number) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
const indicatorColor = color || item.payload?.fill || item.color;
return (
<div
key={item.dataKey}
key={String(item.dataKey ?? index)}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
@@ -264,7 +272,7 @@ const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
Pick<DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
@@ -289,8 +297,8 @@ const ChartLegendContent = React.forwardRef<
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
.filter((item: LegendPayload) => item.type !== "none")
.map((item: LegendPayload) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
+1
View File
@@ -320,6 +320,7 @@ export interface FilteredTrafficStats {
bandwidth_history: BandwidthDataPoint[];
period_bytes_sent: number;
period_bytes_received: number;
period_requests: number;
domains: Record<string, DomainAccess>;
unique_ips: string[];
}
+4 -2
View File
@@ -13,7 +13,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -29,7 +29,9 @@
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
"dist/types/**/*.ts"
"dist/types/**/*.ts",
".next/dev/types/**/*.ts",
"dist/dev/types/**/*.ts"
],
"exclude": ["node_modules", "nodecar", "src-tauri/target"]
}