From 35723de96a015326baf0b893c9d5b6f0946e0f27 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:21:31 +0400 Subject: [PATCH] feat: dns block lists --- next-env.d.ts | 2 +- src-tauri/src/api_server.rs | 1 + src-tauri/src/app_dirs.rs | 5 + src-tauri/src/auto_updater.rs | 1 + src-tauri/src/bin/proxy_server.rs | 16 +- src-tauri/src/browser.rs | 1 + src-tauri/src/browser_runner.rs | 30 + src-tauri/src/dns_blocklist.rs | 343 +++++++ src-tauri/src/ephemeral_dirs.rs | 1 + src-tauri/src/lib.rs | 23 +- src-tauri/src/mcp_server.rs | 89 ++ src-tauri/src/profile/manager.rs | 44 + src-tauri/src/profile/types.rs | 2 + src-tauri/src/profile_importer.rs | 3 + src-tauri/src/proxy_manager.rs | 17 + src-tauri/src/proxy_runner.rs | 6 +- src-tauri/src/proxy_server.rs | 224 ++++- src-tauri/src/proxy_storage.rs | 8 + src/app/layout.tsx | 21 +- src/app/page.tsx | 2 + src/components/bandwidth-mini-chart.tsx | 7 +- src/components/client-providers.tsx | 25 + src/components/create-profile-dialog.tsx | 42 + src/components/dns-blocklist-dialog.tsx | 147 +++ src/components/profile-data-table.tsx | 14 + src/components/profile-info-dialog.tsx | 110 ++- src/components/settings-dialog.tsx | 1027 +++++++++++---------- src/components/theme-provider.tsx | 117 ++- src/components/traffic-details-dialog.tsx | 7 +- src/components/ui/chart.tsx | 2 +- src/components/ui/sonner.tsx | 2 +- src/i18n/locales/en.json | 17 + src/i18n/locales/es.json | 17 + src/i18n/locales/fr.json | 17 + src/i18n/locales/ja.json | 17 + src/i18n/locales/pt.json | 17 + src/i18n/locales/ru.json | 17 + src/i18n/locales/zh.json | 17 + src/types.ts | 1 + 39 files changed, 1880 insertions(+), 579 deletions(-) create mode 100644 src-tauri/src/dns_blocklist.rs create mode 100644 src/components/client-providers.tsx create mode 100644 src/components/dns-blocklist-dialog.tsx diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..b87975d 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./dist/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index f9ac822..01043f5 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -611,6 +611,7 @@ async fn create_profile( wayfern_config, request.group_id.clone(), false, + None, ) .await { diff --git a/src-tauri/src/app_dirs.rs b/src-tauri/src/app_dirs.rs index 7763204..b1dadb1 100644 --- a/src-tauri/src/app_dirs.rs +++ b/src-tauri/src/app_dirs.rs @@ -104,6 +104,10 @@ pub fn extensions_dir() -> PathBuf { data_dir().join("extensions") } +pub fn dns_blocklist_dir() -> PathBuf { + cache_dir().join("dns_blocklists") +} + #[cfg(test)] thread_local! { static TEST_DATA_DIR: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; @@ -188,6 +192,7 @@ mod tests { assert!(proxy_workers_dir().ends_with("proxy_workers")); assert!(vpn_dir().ends_with("vpn")); assert!(extensions_dir().ends_with("extensions")); + assert!(dns_blocklist_dir().ends_with("dns_blocklists")); } #[test] diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index b57c2d2..3eed136 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -699,6 +699,7 @@ mod tests { proxy_bypass_rules: Vec::new(), created_by_id: None, created_by_email: None, + dns_blocklist: None, } } diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index 4fc81e0..f325f0e 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -152,6 +152,11 @@ async fn main() { Arg::new("bypass-rules") .long("bypass-rules") .help("JSON array of bypass rules (hostnames, IPs, or regex patterns)"), + ) + .arg( + Arg::new("blocklist-file") + .long("blocklist-file") + .help("Path to DNS blocklist file (one domain per line)"), ), ) .subcommand( @@ -235,8 +240,17 @@ async fn main() { .get_one::("bypass-rules") .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); + let blocklist_file = start_matches.get_one::("blocklist-file").cloned(); - match start_proxy_process_with_profile(upstream_url, port, profile_id, bypass_rules).await { + match start_proxy_process_with_profile( + upstream_url, + port, + profile_id, + bypass_rules, + blocklist_file, + ) + .await + { Ok(config) => { // Output the configuration as JSON for the Rust side to parse // Use println! here because this needs to go to stdout for parsing diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 9dcc6e0..f555535 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -1216,6 +1216,7 @@ mod tests { proxy_bypass_rules: Vec::new(), created_by_id: None, created_by_email: None, + dns_blocklist: None, }; let path = profile.get_profile_data_path(&profiles_dir); diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 2023825..d7b9855 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -38,6 +38,26 @@ impl BrowserRunner { crate::app_dirs::binaries_dir() } + /// Resolve the DNS blocklist level to a cached file path. + /// If a level is set but the cache is missing, fetches on demand (blocks until done). + async fn resolve_blocklist_file( + profile: &crate::profile::BrowserProfile, + ) -> Result, String> { + let Some(ref level_str) = profile.dns_blocklist else { + return Ok(None); + }; + let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else { + return Ok(None); + }; + if level == crate::dns_blocklist::BlocklistLevel::None { + return Ok(None); + } + let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level) + .await + .map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?; + Ok(Some(path.to_string_lossy().to_string())) + } + /// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy, /// then resolve the proxy settings with profile-specific sid for sticky sessions. /// Resolve proxy settings for a profile, returning an error for dynamic proxy failures. @@ -168,6 +188,7 @@ impl BrowserRunner { // Start the proxy and get local proxy settings // If proxy startup fails, DO NOT launch Camoufox - it requires local proxy let profile_id_str = profile.id.to_string(); + let blocklist_file = Self::resolve_blocklist_file(profile).await?; let local_proxy = PROXY_MANAGER .start_proxy( app_handle.clone(), @@ -175,6 +196,7 @@ impl BrowserRunner { 0, // Use 0 as temporary PID, will be updated later Some(&profile_id_str), profile.proxy_bypass_rules.clone(), + blocklist_file, ) .await .map_err(|e| { @@ -427,6 +449,7 @@ impl BrowserRunner { // Start the proxy and get local proxy settings // If proxy startup fails, DO NOT launch Wayfern - it requires local proxy let profile_id_str = profile.id.to_string(); + let blocklist_file = Self::resolve_blocklist_file(profile).await?; let local_proxy = PROXY_MANAGER .start_proxy( app_handle.clone(), @@ -434,6 +457,7 @@ impl BrowserRunner { 0, // Use 0 as temporary PID, will be updated later Some(&profile_id_str), profile.proxy_bypass_rules.clone(), + blocklist_file, ) .await .map_err(|e| { @@ -751,6 +775,9 @@ impl BrowserRunner { let profile_id_str = profile.id.to_string(); // Start local proxy - if this fails, DO NOT launch browser + let blocklist_file = Self::resolve_blocklist_file(profile) + .await + .map_err(|e| -> Box { e.into() })?; let internal_proxy = PROXY_MANAGER .start_proxy( app_handle.clone(), @@ -758,6 +785,7 @@ impl BrowserRunner { temp_pid, Some(&profile_id_str), profile.proxy_bypass_rules.clone(), + blocklist_file, ) .await .map_err(|e| { @@ -2280,6 +2308,7 @@ pub async fn launch_browser_profile( // Always start a local proxy, even if there's no upstream proxy // This allows for traffic monitoring and future features + let blocklist_file = BrowserRunner::resolve_blocklist_file(&profile_for_launch).await?; match PROXY_MANAGER .start_proxy( app_handle.clone(), @@ -2287,6 +2316,7 @@ pub async fn launch_browser_profile( temp_pid, Some(&profile_id_str), profile_for_launch.proxy_bypass_rules.clone(), + blocklist_file, ) .await { diff --git a/src-tauri/src/dns_blocklist.rs b/src-tauri/src/dns_blocklist.rs new file mode 100644 index 0000000..852435f --- /dev/null +++ b/src-tauri/src/dns_blocklist.rs @@ -0,0 +1,343 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +use crate::app_dirs; + +const REFRESH_INTERVAL: Duration = Duration::from_secs(43200); // 12 hours + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum BlocklistLevel { + #[default] + None, + Light, + Normal, + Pro, + ProPlus, + Ultimate, +} + +impl BlocklistLevel { + pub fn parse_level(s: &str) -> Option { + match s { + "light" => Some(Self::Light), + "normal" => Some(Self::Normal), + "pro" => Some(Self::Pro), + "pro_plus" => Some(Self::ProPlus), + "ultimate" => Some(Self::Ultimate), + "none" => Some(Self::None), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::None => "none", + Self::Light => "light", + Self::Normal => "normal", + Self::Pro => "pro", + Self::ProPlus => "pro_plus", + Self::Ultimate => "ultimate", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + Self::None => "None", + Self::Light => "Light", + Self::Normal => "Normal", + Self::Pro => "Pro", + Self::ProPlus => "Pro++", + Self::Ultimate => "Ultimate", + } + } + + pub fn url(&self) -> Option<&'static str> { + match self { + Self::None => None, + Self::Light => { + Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt") + } + Self::Normal => { + Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/multi.txt") + } + Self::Pro => Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.txt"), + Self::ProPlus => { + Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.plus.txt") + } + Self::Ultimate => { + Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/ultimate.txt") + } + } + } + + pub fn filename(&self) -> Option<&'static str> { + match self { + Self::None => None, + Self::Light => Some("light.txt"), + Self::Normal => Some("multi.txt"), + Self::Pro => Some("pro.txt"), + Self::ProPlus => Some("pro.plus.txt"), + Self::Ultimate => Some("ultimate.txt"), + } + } + + pub fn all_downloadable() -> &'static [BlocklistLevel] { + &[ + Self::Light, + Self::Normal, + Self::Pro, + Self::ProPlus, + Self::Ultimate, + ] + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlocklistCacheStatus { + pub level: String, + pub display_name: String, + pub entry_count: usize, + pub file_size_bytes: u64, + pub last_updated: Option, + pub is_fresh: bool, + pub is_cached: bool, +} + +pub struct BlocklistManager; + +lazy_static::lazy_static! { + static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .expect("Failed to create HTTP client"); +} + +impl BlocklistManager { + pub fn instance() -> &'static BlocklistManager { + &BLOCKLIST_MANAGER + } + + fn cache_dir() -> PathBuf { + app_dirs::dns_blocklist_dir() + } + + pub fn cached_file_path(level: BlocklistLevel) -> Option { + level.filename().map(|f| Self::cache_dir().join(f)) + } + + pub fn is_cache_fresh(level: BlocklistLevel) -> bool { + let Some(path) = Self::cached_file_path(level) else { + return false; + }; + if !path.exists() { + return false; + } + match std::fs::metadata(&path).and_then(|m| m.modified()) { + Ok(modified) => SystemTime::now() + .duration_since(modified) + .map(|age| age < REFRESH_INTERVAL) + .unwrap_or(false), + Err(_) => false, + } + } + + pub async fn fetch_blocklist(level: BlocklistLevel) -> Result { + let url = level + .url() + .ok_or_else(|| format!("No URL for level {:?}", level))?; + let path = + Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?; + + let cache_dir = Self::cache_dir(); + std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?; + + log::info!( + "[dns-blocklist] Fetching {} from {}", + level.display_name(), + url + ); + + let response = HTTP_CLIENT + .get(url) + .send() + .await + .map_err(|e| format!("Failed to fetch blocklist: {e}"))?; + + if !response.status().is_success() { + return Err(format!("HTTP {} when fetching {}", response.status(), url)); + } + + let body = response + .text() + .await + .map_err(|e| format!("Failed to read response body: {e}"))?; + + // Write atomically: write to temp file, then rename + let tmp_path = path.with_extension("tmp"); + std::fs::write(&tmp_path, &body).map_err(|e| format!("Failed to write blocklist: {e}"))?; + std::fs::rename(&tmp_path, &path).map_err(|e| format!("Failed to rename blocklist: {e}"))?; + + let entry_count = body + .lines() + .filter(|l| !l.starts_with('#') && !l.trim().is_empty()) + .count(); + log::info!( + "[dns-blocklist] Cached {} ({} domains)", + level.display_name(), + entry_count + ); + + Ok(path) + } + + pub async fn ensure_cached(level: BlocklistLevel) -> Result { + if let Some(path) = Self::cached_file_path(level) { + if path.exists() { + return Ok(path); + } + } + Self::fetch_blocklist(level).await + } + + pub async fn refresh_all_stale(&self) { + for &level in BlocklistLevel::all_downloadable() { + if !Self::is_cache_fresh(level) { + if let Err(e) = Self::fetch_blocklist(level).await { + log::error!( + "[dns-blocklist] Failed to refresh {}: {e}", + level.display_name() + ); + let _ = crate::events::emit( + "dns-blocklist-refresh-failed", + serde_json::json!({ + "level": level.as_str(), + "error": e, + }), + ); + } + } + } + } + + pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option { + Self::cached_file_path(level).filter(|p| p.exists()) + } + + pub fn get_cache_status() -> Vec { + BlocklistLevel::all_downloadable() + .iter() + .map(|&level| { + let path = Self::cached_file_path(level); + let metadata = path.as_ref().and_then(|p| std::fs::metadata(p).ok()); + let is_cached = metadata.is_some(); + + let entry_count = if is_cached { + path + .as_ref() + .and_then(|p| std::fs::read_to_string(p).ok()) + .map(|content| { + content + .lines() + .filter(|l| !l.starts_with('#') && !l.trim().is_empty()) + .count() + }) + .unwrap_or(0) + } else { + 0 + }; + + let file_size_bytes = metadata.as_ref().map(|m| m.len()).unwrap_or(0); + + let last_updated = metadata + .as_ref() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + + BlocklistCacheStatus { + level: level.as_str().to_string(), + display_name: level.display_name().to_string(), + entry_count, + file_size_bytes, + last_updated, + is_fresh: Self::is_cache_fresh(level), + is_cached, + } + }) + .collect() + } +} + +lazy_static::lazy_static! { + static ref BLOCKLIST_MANAGER: BlocklistManager = BlocklistManager; +} + +// Tauri commands + +#[tauri::command] +pub async fn get_dns_blocklist_cache_status() -> Result, String> { + Ok(BlocklistManager::get_cache_status()) +} + +#[tauri::command] +pub async fn refresh_dns_blocklists() -> Result<(), String> { + for &level in BlocklistLevel::all_downloadable() { + BlocklistManager::fetch_blocklist(level).await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_level_roundtrip() { + for &level in BlocklistLevel::all_downloadable() { + let s = level.as_str(); + let parsed = BlocklistLevel::parse_level(s); + assert_eq!(parsed, Some(level), "Roundtrip failed for {s}"); + } + assert_eq!( + BlocklistLevel::parse_level("none"), + Some(BlocklistLevel::None) + ); + } + + #[test] + fn test_level_urls_all_present() { + for &level in BlocklistLevel::all_downloadable() { + assert!( + level.url().is_some(), + "{} should have a URL", + level.as_str() + ); + assert!( + level.filename().is_some(), + "{} should have a filename", + level.as_str() + ); + } + assert!(BlocklistLevel::None.url().is_none()); + assert!(BlocklistLevel::None.filename().is_none()); + } + + #[test] + fn test_cache_status_returns_all_levels() { + let statuses = BlocklistManager::get_cache_status(); + assert_eq!(statuses.len(), 5); + assert_eq!(statuses[0].level, "light"); + assert_eq!(statuses[1].level, "normal"); + assert_eq!(statuses[2].level, "pro"); + assert_eq!(statuses[3].level, "pro_plus"); + assert_eq!(statuses[4].level, "ultimate"); + } + + #[test] + fn test_cache_fresh_returns_false_when_missing() { + assert!(!BlocklistManager::is_cache_fresh(BlocklistLevel::Light)); + assert!(!BlocklistManager::is_cache_fresh(BlocklistLevel::None)); + } +} diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index f29409d..42db82d 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -277,6 +277,7 @@ mod tests { proxy_bypass_rules: Vec::new(), created_by_id: None, created_by_email: None, + dns_blocklist: None, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f44528f..5d98832 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ mod browser_version_manager; pub mod camoufox; mod camoufox_manager; mod default_browser; +pub mod dns_blocklist; mod downloaded_browsers_registry; mod downloader; mod ephemeral_dirs; @@ -65,9 +66,9 @@ use browser_runner::{ use profile::manager::{ check_browser_status, clone_profile, create_browser_profile_new, delete_profile, - list_browser_profiles, rename_profile, update_camoufox_config, update_profile_note, - update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn, - update_wayfern_config, + list_browser_profiles, rename_profile, update_camoufox_config, update_profile_dns_blocklist, + update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules, + update_profile_tags, update_profile_vpn, update_wayfern_config, }; use browser_version_manager::{ @@ -1132,6 +1133,7 @@ async fn generate_sample_fingerprint( proxy_bypass_rules: Vec::new(), created_by_id: None, created_by_email: None, + dns_blocklist: None, }; if browser == "camoufox" { @@ -1462,6 +1464,17 @@ pub fn run() { } }); + // DNS blocklist refresh task (every 12 hours) + tauri::async_runtime::spawn(async move { + let manager = dns_blocklist::BlocklistManager::instance(); + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); + interval.tick().await; // Skip the immediate first tick + loop { + interval.tick().await; + manager.refresh_all_stale().await; + } + }); + tauri::async_runtime::spawn(async move { let updater = app_auto_updater::AppAutoUpdater::instance(); let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60)); @@ -1805,6 +1818,7 @@ pub fn run() { update_profile_tags, update_profile_note, update_profile_proxy_bypass_rules, + update_profile_dns_blocklist, check_browser_status, kill_browser_profile, rename_profile, @@ -1952,6 +1966,9 @@ pub fn run() { synchronizer::stop_sync_session, synchronizer::remove_sync_follower, synchronizer::get_sync_sessions, + // DNS blocklist commands + dns_blocklist::get_dns_blocklist_cache_status, + dns_blocklist::refresh_dns_blocklists, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 66ee40a..0acb16b 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1009,6 +1009,36 @@ impl McpServer { "required": ["profile_id", "rules"] }), }, + McpTool { + name: "update_profile_dns_blocklist".to_string(), + description: + "Update the DNS blocklist level for a profile. Blocks ads, trackers, and malware domains at the proxy level." + .to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the profile to update" + }, + "level": { + "type": "string", + "enum": ["none", "light", "normal", "pro", "pro_plus", "ultimate"], + "description": "DNS blocklist level. 'none' disables blocking." + } + }, + "required": ["profile_id", "level"] + }), + }, + McpTool { + name: "get_dns_blocklist_status".to_string(), + description: "Get the cache status of all DNS blocklist tiers including entry counts and freshness.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, McpTool { name: "list_extensions".to_string(), description: "List all managed browser extensions. Requires Pro subscription.".to_string(), @@ -1482,6 +1512,9 @@ impl McpServer { .handle_update_profile_proxy_bypass_rules(&arguments) .await } + // DNS blocklist management + "update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(&arguments).await, + "get_dns_blocklist_status" => self.handle_get_dns_blocklist_status().await, // Extension management "list_extensions" => self.handle_list_extensions().await, "list_extension_groups" => self.handle_list_extension_groups().await, @@ -1806,6 +1839,7 @@ impl McpServer { let mut profile = ProfileManager::instance() .create_profile_with_group( app_handle, name, browser, version, "stable", proxy_id, None, None, None, group_id, false, + None, ) .await .map_err(|e| McpError { @@ -3119,6 +3153,61 @@ impl McpServer { })) } + async fn handle_update_profile_dns_blocklist( + &self, + arguments: &serde_json::Value, + ) -> Result { + let profile_id = arguments + .get("profile_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing profile_id".to_string(), + })?; + + let level = arguments + .get("level") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing level".to_string(), + })?; + + let dns_blocklist = if level == "none" { + None + } else { + Some(level.to_string()) + }; + + let profile = ProfileManager::instance() + .update_profile_dns_blocklist(profile_id, dns_blocklist) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to update DNS blocklist: {e}"), + })?; + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!( + "DNS blocklist updated for profile '{}': {}", + profile.name, + level + ) + }] + })) + } + + async fn handle_get_dns_blocklist_status(&self) -> Result { + let statuses = crate::dns_blocklist::BlocklistManager::get_cache_status(); + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&statuses).unwrap_or_default() + }] + })) + } + async fn handle_list_extensions(&self) -> Result { if !CLOUD_AUTH.has_active_paid_subscription().await { return Err(McpError { diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 180b834..7ccd148 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -50,6 +50,7 @@ impl ProfileManager { wayfern_config: Option, group_id: Option, ephemeral: bool, + dns_blocklist: Option, ) -> Result> { if proxy_id.is_some() && vpn_id.is_some() { return Err("Cannot set both proxy_id and vpn_id".into()); @@ -158,6 +159,7 @@ impl ProfileManager { proxy_bypass_rules: Vec::new(), created_by_id: None, created_by_email: None, + dns_blocklist: None, }; match self @@ -257,6 +259,7 @@ impl ProfileManager { proxy_bypass_rules: Vec::new(), created_by_id: None, created_by_email: None, + dns_blocklist: None, }; match self @@ -310,6 +313,7 @@ impl ProfileManager { proxy_bypass_rules: Vec::new(), created_by_id: None, created_by_email: None, + dns_blocklist, }; // Save profile info @@ -760,6 +764,30 @@ impl ProfileManager { Ok(profile) } + pub fn update_profile_dns_blocklist( + &self, + profile_id: &str, + dns_blocklist: Option, + ) -> Result> { + let profile_uuid = + uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?; + let profiles = self.list_profiles()?; + let mut profile = profiles + .into_iter() + .find(|p| p.id == profile_uuid) + .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; + + profile.dns_blocklist = dns_blocklist; + + self.save_profile(&profile)?; + + if let Err(e) = events::emit_empty("profiles-changed") { + log::warn!("Warning: Failed to emit profiles-changed event: {e}"); + } + + Ok(profile) + } + pub fn delete_multiple_profiles( &self, app_handle: &tauri::AppHandle, @@ -902,6 +930,7 @@ impl ProfileManager { proxy_bypass_rules: source.proxy_bypass_rules, created_by_id: None, created_by_email: None, + dns_blocklist: source.dns_blocklist, }; self.save_profile(&new_profile)?; @@ -1957,6 +1986,7 @@ pub async fn create_browser_profile_with_group( wayfern_config: Option, group_id: Option, ephemeral: bool, + dns_blocklist: Option, ) -> Result { let profile_manager = ProfileManager::instance(); profile_manager @@ -1972,6 +2002,7 @@ pub async fn create_browser_profile_with_group( wayfern_config, group_id, ephemeral, + dns_blocklist, ) .await .map_err(|e| format!("Failed to create profile: {e}")) @@ -2047,6 +2078,17 @@ pub fn update_profile_proxy_bypass_rules( .map_err(|e| format!("Failed to update proxy bypass rules: {e}")) } +#[tauri::command] +pub fn update_profile_dns_blocklist( + profile_id: String, + dns_blocklist: Option, +) -> Result { + let profile_manager = ProfileManager::instance(); + profile_manager + .update_profile_dns_blocklist(&profile_id, dns_blocklist) + .map_err(|e| format!("Failed to update DNS blocklist: {e}")) +} + #[tauri::command] pub async fn check_browser_status( app_handle: tauri::AppHandle, @@ -2085,6 +2127,7 @@ pub async fn create_browser_profile_new( wayfern_config: Option, group_id: Option, ephemeral: Option, + dns_blocklist: Option, ) -> Result { let fingerprint_os = camoufox_config .as_ref() @@ -2112,6 +2155,7 @@ pub async fn create_browser_profile_new( wayfern_config, group_id, ephemeral.unwrap_or(false), + dns_blocklist, ) .await } diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 13b7282..8776598 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -65,6 +65,8 @@ pub struct BrowserProfile { pub created_by_id: Option, #[serde(default)] pub created_by_email: Option, + #[serde(default)] + pub dns_blocklist: Option, } pub fn default_release_type() -> String { diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 77f89fb..2abb30e 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -582,6 +582,7 @@ impl ProfileImporter { proxy_bypass_rules: Vec::new(), created_by_id: None, created_by_email: None, + dns_blocklist: None, }; match self @@ -660,6 +661,7 @@ impl ProfileImporter { proxy_bypass_rules: Vec::new(), created_by_id: None, created_by_email: None, + dns_blocklist: None, }; match self @@ -709,6 +711,7 @@ impl ProfileImporter { proxy_bypass_rules: Vec::new(), created_by_id: None, created_by_email: None, + dns_blocklist: None, }; self.profile_manager.save_profile(&profile)?; diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index cd3a198..d7a32f4 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -77,6 +77,7 @@ pub struct ProxyInfo { pub local_port: u16, // Optional profile ID to which this proxy instance is logically tied pub profile_id: Option, + pub blocklist_file: Option, } // Proxy check result cache @@ -1675,6 +1676,7 @@ impl ProxyManager { browser_pid: u32, profile_id: Option<&str>, bypass_rules: Vec, + blocklist_file: Option, ) -> Result { if let Some(name) = profile_id { // Check if we have an active proxy recorded for this profile @@ -1802,6 +1804,11 @@ impl ProxyManager { proxy_cmd = proxy_cmd.arg("--bypass-rules").arg(rules_json); } + // Add blocklist file path if provided + if let Some(ref path) = blocklist_file { + proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path); + } + // Execute the command and wait for it to complete // The donut-proxy binary should start the worker and then exit let output = proxy_cmd @@ -1847,6 +1854,7 @@ impl ProxyManager { .unwrap_or_else(|| "DIRECT".to_string()), local_port, profile_id: profile_id.map(|s| s.to_string()), + blocklist_file: blocklist_file.clone(), }; // Wait for the local proxy port to be ready to accept connections @@ -2345,6 +2353,7 @@ mod tests { upstream_type: "http".to_string(), local_port: (8000 + i) as u16, profile_id: None, + blocklist_file: None, }; // Add proxy @@ -2671,6 +2680,7 @@ mod tests { upstream_type: "http".to_string(), local_port: port, profile_id: profile_id.map(|s| s.to_string()), + blocklist_file: None, } } @@ -2898,6 +2908,7 @@ mod tests { pid: Some(live_pid), profile_id: None, bypass_rules: Vec::new(), + blocklist_file: None, }; let dead_config = ProxyConfig { id: dead_id.clone(), @@ -2908,6 +2919,7 @@ mod tests { pid: Some(dead_pid), profile_id: None, bypass_rules: Vec::new(), + blocklist_file: None, }; save_proxy_config(&live_config).unwrap(); @@ -2946,6 +2958,7 @@ mod tests { pid: Some(12345), profile_id: Some("prof_abc".to_string()), bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()], + blocklist_file: None, }; // Save @@ -3064,6 +3077,7 @@ mod tests { upstream_type: "http".to_string(), local_port: 9201, profile_id: Some("profile_alpha".to_string()), + blocklist_file: None, }; let info_b = ProxyInfo { id: "px_shared_b".to_string(), @@ -3073,6 +3087,7 @@ mod tests { upstream_type: "http".to_string(), local_port: 9202, profile_id: Some("profile_beta".to_string()), + blocklist_file: None, }; pm.insert_active_proxy(3001, info_a); @@ -3260,6 +3275,7 @@ mod tests { pid: Some(dead_pid), profile_id: None, bypass_rules: Vec::new(), + blocklist_file: None, }; save_proxy_config(&config).unwrap(); @@ -3432,6 +3448,7 @@ mod tests { upstream_type: ptype.to_string(), local_port: 9300 + i as u16, profile_id: Some(format!("profile_{ptype}")), + blocklist_file: None, }; pm.insert_active_proxy(4000 + i as u32, info); } diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index 8a6fa52..bf016b3 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -12,7 +12,7 @@ pub async fn start_proxy_process( upstream_url: Option, port: Option, ) -> Result> { - start_proxy_process_with_profile(upstream_url, port, None, Vec::new()).await + start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None).await } pub async fn start_proxy_process_with_profile( @@ -20,6 +20,7 @@ pub async fn start_proxy_process_with_profile( port: Option, profile_id: Option, bypass_rules: Vec, + blocklist_file: Option, ) -> Result> { let id = generate_proxy_id(); let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string()); @@ -33,7 +34,8 @@ pub async fn start_proxy_process_with_profile( let config = ProxyConfig::new(id.clone(), upstream, Some(local_port)) .with_profile_id(profile_id.clone()) - .with_bypass_rules(bypass_rules); + .with_bypass_rules(bypass_rules) + .with_blocklist_file(blocklist_file); save_proxy_config(&config)?; // Log profile_id for debugging diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index 6852ea4..6cc0f0f 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -7,6 +7,7 @@ use hyper::service::service_fn; use hyper::{Method, Request, Response, StatusCode}; use hyper_util::rt::TokioIo; use regex_lite::Regex; +use std::collections::HashSet; use std::convert::Infallible; use std::io; use std::net::SocketAddr; @@ -51,6 +52,58 @@ impl BypassMatcher { } } +#[derive(Clone)] +pub struct BlocklistMatcher { + domains: Arc>, +} + +impl Default for BlocklistMatcher { + fn default() -> Self { + Self::new() + } +} + +impl BlocklistMatcher { + pub fn new() -> Self { + Self { + domains: Arc::new(HashSet::new()), + } + } + + pub fn from_file(path: &str) -> Result> { + let content = std::fs::read_to_string(path)?; + let domains: HashSet = content + .lines() + .filter(|line| !line.starts_with('#') && !line.trim().is_empty()) + .map(|line| line.trim().to_lowercase()) + .collect(); + log::info!("[blocklist] Loaded {} domains from {}", domains.len(), path); + Ok(Self { + domains: Arc::new(domains), + }) + } + + pub fn is_blocked(&self, host: &str) -> bool { + if self.domains.is_empty() { + return false; + } + let host_lower = host.to_lowercase(); + // Exact match + if self.domains.contains(host_lower.as_str()) { + return true; + } + // Suffix matching: check parent domains (like uBlock) + let mut start = 0; + while let Some(dot_pos) = host_lower[start..].find('.') { + start += dot_pos + 1; + if self.domains.contains(&host_lower[start..]) { + return true; + } + } + false + } +} + /// Wrapper stream that counts bytes read and written struct CountingStream { inner: S, @@ -167,20 +220,22 @@ async fn handle_request( req: Request, upstream_url: Option, bypass_matcher: BypassMatcher, + blocklist_matcher: BlocklistMatcher, ) -> Result>, Infallible> { // Handle CONNECT method for HTTPS tunneling if req.method() == Method::CONNECT { - return handle_connect(req, upstream_url, bypass_matcher).await; + return handle_connect(req, upstream_url, bypass_matcher, blocklist_matcher).await; } // Handle regular HTTP requests - handle_http(req, upstream_url, bypass_matcher).await + handle_http(req, upstream_url, bypass_matcher, blocklist_matcher).await } async fn handle_connect( req: Request, upstream_url: Option, bypass_matcher: BypassMatcher, + blocklist_matcher: BlocklistMatcher, ) -> Result>, Infallible> { let authority = req.uri().authority().cloned(); @@ -196,6 +251,14 @@ async fn handle_connect( (&target_addr[..], 443) }; + // Block if domain is in the DNS blocklist (before any connection) + if blocklist_matcher.is_blocked(target_host) { + log::debug!("[blocklist] Blocked CONNECT to {}", target_host); + let mut response = Response::new(Full::new(Bytes::from("Blocked by DNS blocklist"))); + *response.status_mut() = StatusCode::FORBIDDEN; + return Ok(response); + } + // If no upstream proxy, or bypass rule matches, connect directly if upstream_url.is_none() || upstream_url @@ -711,6 +774,7 @@ async fn handle_http( req: Request, upstream_url: Option, bypass_matcher: BypassMatcher, + blocklist_matcher: BlocklistMatcher, ) -> Result>, Infallible> { // Extract domain for traffic tracking let domain = req @@ -719,6 +783,14 @@ async fn handle_http( .map(|h| h.to_string()) .unwrap_or_else(|| "unknown".to_string()); + // Block if domain is in the DNS blocklist (before any connection) + if blocklist_matcher.is_blocked(&domain) { + log::debug!("[blocklist] Blocked HTTP request to {}", domain); + let mut response = Response::new(Full::new(Bytes::from("Blocked by DNS blocklist"))); + *response.status_mut() = StatusCode::FORBIDDEN; + return Ok(response); + } + log::error!( "DEBUG: Handling HTTP request: {} {} (host: {:?})", req.method(), @@ -888,6 +960,7 @@ pub async fn handle_proxy_connection( mut stream: tokio::net::TcpStream, upstream_url: Option, bypass_matcher: BypassMatcher, + blocklist_matcher: BlocklistMatcher, ) { let _ = stream.set_nodelay(true); @@ -942,8 +1015,14 @@ pub async fn handle_proxy_connection( } } - let _ = - handle_connect_from_buffer(stream, full_request, upstream_url, bypass_matcher).await; + let _ = handle_connect_from_buffer( + stream, + full_request, + upstream_url, + bypass_matcher, + blocklist_matcher, + ) + .await; return; } @@ -955,8 +1034,14 @@ pub async fn handle_proxy_connection( inner: stream, }; let io = TokioIo::new(prepended_reader); - let service = - service_fn(move |req| handle_request(req, upstream_url.clone(), bypass_matcher.clone())); + let service = service_fn(move |req| { + handle_request( + req, + upstream_url.clone(), + bypass_matcher.clone(), + blocklist_matcher.clone(), + ) + }); let _ = http1::Builder::new().serve_connection(io, service).await; } @@ -1128,6 +1213,17 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box m, + Err(e) => { + log::error!("[blocklist] Failed to load from {}: {}", path, e); + BlocklistMatcher::new() + } + } + } else { + BlocklistMatcher::new() + }; // Keep the runtime alive with an infinite loop // This ensures the process doesn't exit even if there are no active connections @@ -1136,8 +1232,9 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box { let upstream = upstream_url.clone(); let matcher = bypass_matcher.clone(); + let blocker = blocklist_matcher.clone(); tokio::task::spawn(async move { - handle_proxy_connection(stream, upstream, matcher).await; + handle_proxy_connection(stream, upstream, matcher, blocker).await; }); } Err(e) => { @@ -1155,6 +1252,7 @@ async fn handle_connect_from_buffer( request_buffer: Vec, upstream_url: Option, bypass_matcher: BypassMatcher, + blocklist_matcher: BlocklistMatcher, ) -> Result<(), Box> { // Parse the CONNECT request from the buffer let request_str = String::from_utf8_lossy(&request_buffer); @@ -1185,6 +1283,15 @@ async fn handle_connect_from_buffer( (target, 443) }; + // Block if domain is in the DNS blocklist (before any connection) + if blocklist_matcher.is_blocked(target_host) { + log::debug!("[blocklist] Blocked CONNECT tunnel to {}", target_host); + let _ = client_stream + .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 24\r\n\r\nBlocked by DNS blocklist") + .await; + return Ok(()); + } + // Record domain access in traffic tracker let domain = target_host.to_string(); if let Some(tracker) = get_traffic_tracker() { @@ -1362,3 +1469,106 @@ async fn handle_connect_from_buffer( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_blocklist_exact_match() { + let mut matcher = BlocklistMatcher::new(); + let mut domains = HashSet::new(); + domains.insert("example.com".to_string()); + domains.insert("tracker.net".to_string()); + matcher.domains = Arc::new(domains); + + assert!(matcher.is_blocked("example.com")); + assert!(matcher.is_blocked("tracker.net")); + assert!(!matcher.is_blocked("safe.com")); + } + + #[test] + fn test_blocklist_subdomain_match() { + let mut matcher = BlocklistMatcher::new(); + let mut domains = HashSet::new(); + domains.insert("example.com".to_string()); + matcher.domains = Arc::new(domains); + + assert!(matcher.is_blocked("foo.example.com")); + assert!(matcher.is_blocked("bar.baz.example.com")); + assert!(matcher.is_blocked("a.b.c.example.com")); + } + + #[test] + fn test_blocklist_no_false_positives() { + let mut matcher = BlocklistMatcher::new(); + let mut domains = HashSet::new(); + domains.insert("example.com".to_string()); + matcher.domains = Arc::new(domains); + + // "notexample.com" should NOT match "example.com" + assert!(!matcher.is_blocked("notexample.com")); + assert!(!matcher.is_blocked("myexample.com")); + // But subdomain should + assert!(matcher.is_blocked("sub.example.com")); + } + + #[test] + fn test_blocklist_empty_blocks_nothing() { + let matcher = BlocklistMatcher::new(); + assert!(!matcher.is_blocked("anything.com")); + assert!(!matcher.is_blocked("example.com")); + } + + #[test] + fn test_blocklist_case_insensitive() { + let mut matcher = BlocklistMatcher::new(); + let mut domains = HashSet::new(); + domains.insert("example.com".to_string()); + matcher.domains = Arc::new(domains); + + assert!(matcher.is_blocked("EXAMPLE.COM")); + assert!(matcher.is_blocked("Example.Com")); + assert!(matcher.is_blocked("FOO.EXAMPLE.COM")); + } + + #[test] + fn test_blocklist_from_file() { + let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); + writeln!(tmpfile, "# This is a comment").unwrap(); + writeln!(tmpfile).unwrap(); + writeln!(tmpfile, "tracker.example.com").unwrap(); + writeln!(tmpfile, "ads.network.com").unwrap(); + writeln!(tmpfile, "# Another comment").unwrap(); + writeln!(tmpfile, "malware.site").unwrap(); + tmpfile.flush().unwrap(); + + let matcher = BlocklistMatcher::from_file(tmpfile.path().to_str().unwrap()).unwrap(); + + assert!(matcher.is_blocked("tracker.example.com")); + assert!(matcher.is_blocked("ads.network.com")); + assert!(matcher.is_blocked("malware.site")); + assert!(matcher.is_blocked("sub.malware.site")); + assert!(!matcher.is_blocked("safe.com")); + // Comments and empty lines should be skipped: 3 domains loaded + assert_eq!(matcher.domains.len(), 3); + } + + #[test] + fn test_blocklist_comments_skipped() { + let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); + writeln!(tmpfile, "# Title: HaGeZi's Light DNS Blocklist").unwrap(); + writeln!(tmpfile, "# Description: test").unwrap(); + writeln!(tmpfile, "# Version: 2026.0330.0928.01").unwrap(); + writeln!(tmpfile).unwrap(); + writeln!(tmpfile, "domain1.com").unwrap(); + writeln!(tmpfile, "domain2.com").unwrap(); + tmpfile.flush().unwrap(); + + let matcher = BlocklistMatcher::from_file(tmpfile.path().to_str().unwrap()).unwrap(); + assert_eq!(matcher.domains.len(), 2); + assert!(matcher.is_blocked("domain1.com")); + assert!(matcher.is_blocked("domain2.com")); + } +} diff --git a/src-tauri/src/proxy_storage.rs b/src-tauri/src/proxy_storage.rs index dbb68d9..7ae6c80 100644 --- a/src-tauri/src/proxy_storage.rs +++ b/src-tauri/src/proxy_storage.rs @@ -14,6 +14,8 @@ pub struct ProxyConfig { pub profile_id: Option, #[serde(default)] pub bypass_rules: Vec, + #[serde(default)] + pub blocklist_file: Option, } impl ProxyConfig { @@ -27,6 +29,7 @@ impl ProxyConfig { pid: None, profile_id: None, bypass_rules: Vec::new(), + blocklist_file: None, } } @@ -39,6 +42,11 @@ impl ProxyConfig { self.bypass_rules = bypass_rules; self } + + pub fn with_blocklist_file(mut self, blocklist_file: Option) -> Self { + self.blocklist_file = blocklist_file; + self + } } pub fn get_storage_dir() -> PathBuf { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9bbc4bb..de69bf2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,14 +1,7 @@ -"use client"; import { Geist, Geist_Mono } from "next/font/google"; import "@/styles/globals.css"; import "flag-icons/css/flag-icons.min.css"; -import { useEffect } from "react"; -import { I18nProvider } from "@/components/i18n-provider"; -import { CustomThemeProvider } from "@/components/theme-provider"; -import { Toaster } from "@/components/ui/sonner"; -import { TooltipProvider } from "@/components/ui/tooltip"; -import { WindowDragArea } from "@/components/window-drag-area"; -import { setupLogging } from "@/lib/logger"; +import { ClientProviders } from "@/components/client-providers"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -25,22 +18,12 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - useEffect(() => { - void setupLogging(); - }, []); - return ( - - - - {children} - - - + {children} ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 5130abe..924736d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -515,6 +515,7 @@ export default function Home() { groupId?: string; extensionGroupId?: string; ephemeral?: boolean; + dnsBlocklist?: string; }) => { try { const profile = await invoke( @@ -532,6 +533,7 @@ export default function Home() { profileData.groupId ?? (selectedGroupId !== "default" ? selectedGroupId : undefined), ephemeral: profileData.ephemeral, + dnsBlocklist: profileData.dnsBlocklist, }, ); diff --git a/src/components/bandwidth-mini-chart.tsx b/src/components/bandwidth-mini-chart.tsx index 9a7aca7..18653c8 100644 --- a/src/components/bandwidth-mini-chart.tsx +++ b/src/components/bandwidth-mini-chart.tsx @@ -68,7 +68,12 @@ export function BandwidthMiniChart({ )} >
- + { + void setupLogging(); + }, []); + + return ( + + + + {children} + + + + ); +} diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 1fb812e..fb7f515 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -84,6 +84,7 @@ interface CreateProfileDialogProps { groupId?: string; extensionGroupId?: string; ephemeral?: boolean; + dnsBlocklist?: string; }) => Promise; selectedGroupId?: string; crossOsUnlocked?: boolean; @@ -124,6 +125,7 @@ export function CreateProfileDialog({ useState(null); const [selectedProxyId, setSelectedProxyId] = useState(); const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false); + const [dnsBlocklist, setDnsBlocklist] = useState(""); // Camoufox anti-detect states const [camoufoxConfig, setCamoufoxConfig] = useState(() => ({ @@ -395,6 +397,7 @@ export function CreateProfileDialog({ selectedGroupId !== "default" ? selectedGroupId : undefined, extensionGroupId: selectedExtensionGroupId, ephemeral, + dnsBlocklist: dnsBlocklist || undefined, }); } else { // Default to Camoufox @@ -420,6 +423,7 @@ export function CreateProfileDialog({ selectedGroupId !== "default" ? selectedGroupId : undefined, extensionGroupId: selectedExtensionGroupId, ephemeral, + dnsBlocklist: dnsBlocklist || undefined, }); } } else { @@ -443,6 +447,7 @@ export function CreateProfileDialog({ releaseType: bestVersion.releaseType, proxyId: selectedProxyId, groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, + dnsBlocklist: dnsBlocklist || undefined, }); } @@ -1162,6 +1167,43 @@ export function CreateProfileDialog({ )}
+ {/* DNS Blocklist */} +
+ + +
+ {/* Extension Group */} {extensionGroups.length > 0 && (
diff --git a/src/components/dns-blocklist-dialog.tsx b/src/components/dns-blocklist-dialog.tsx new file mode 100644 index 0000000..ff33ad3 --- /dev/null +++ b/src/components/dns-blocklist-dialog.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuRefreshCw } from "react-icons/lu"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface BlocklistCacheStatus { + level: string; + display_name: string; + entry_count: number; + file_size_bytes: number; + last_updated: number | null; + is_fresh: boolean; + is_cached: boolean; +} + +interface DnsBlocklistDialogProps { + isOpen: boolean; + onClose: () => void; +} + +export function DnsBlocklistDialog({ + isOpen, + onClose, +}: DnsBlocklistDialogProps) { + const { t } = useTranslation(); + const [statuses, setStatuses] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + + const loadStatuses = useCallback(async () => { + try { + const result = await invoke( + "get_dns_blocklist_cache_status", + ); + setStatuses(result); + } catch (e) { + console.error("Failed to load blocklist status:", e); + } + }, []); + + useEffect(() => { + if (isOpen) { + void loadStatuses(); + } + }, [isOpen, loadStatuses]); + + const handleRefreshAll = async () => { + setIsRefreshing(true); + try { + await invoke("refresh_dns_blocklists"); + await loadStatuses(); + } catch (e) { + console.error("Failed to refresh blocklists:", e); + } finally { + setIsRefreshing(false); + } + }; + + const formatSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + const formatDate = (timestamp: number | null) => { + if (!timestamp) return t("dnsBlocklist.notCached"); + return new Date(timestamp * 1000).toLocaleString(); + }; + + return ( + !open && onClose()}> + + + {t("dnsBlocklist.title")} + + +

+ {t("dnsBlocklist.settingsDescription")} +

+ +
+ {statuses.map((status) => ( +
+
+
+ + {status.display_name} + + {status.is_cached ? ( + status.is_fresh ? ( + + {t("dnsBlocklist.fresh")} + + ) : ( + + {t("dnsBlocklist.stale")} + + ) + ) : ( + + {t("dnsBlocklist.notCached")} + + )} +
+ {status.is_cached && ( +
+ {status.entry_count.toLocaleString()}{" "} + {t("dnsBlocklist.domains")} ·{" "} + {formatSize(status.file_size_bytes)} ·{" "} + {formatDate(status.last_updated)} +
+ )} +
+
+ ))} +
+ + +
+
+ ); +} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index d792c7b..3777a63 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -31,6 +31,7 @@ import { import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; import { ProfileBypassRulesDialog, + ProfileDnsBlocklistDialog, ProfileInfoDialog, } from "@/components/profile-info-dialog"; import { Badge } from "@/components/ui/badge"; @@ -934,6 +935,8 @@ export function ProfilesDataTable({ React.useState(null); const [bypassRulesProfile, setBypassRulesProfile] = React.useState(null); + const [dnsBlocklistProfile, setDnsBlocklistProfile] = + React.useState(null); const [launchingProfiles, setLaunchingProfiles] = React.useState>( new Set(), ); @@ -2674,6 +2677,9 @@ export function ProfilesDataTable({ onOpenBypassRules={(profile) => { setBypassRulesProfile(profile); }} + onOpenDnsBlocklist={(profile) => { + setDnsBlocklistProfile(profile); + }} onCloneProfile={onCloneProfile} onLaunchWithSync={onLaunchWithSync} onDeleteProfile={(profile) => { @@ -2756,6 +2762,14 @@ export function ProfilesDataTable({ profileId={bypassRulesProfile?.id ?? null} initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []} /> + { + setDnsBlocklistProfile(null); + }} + profileId={dnsBlocklistProfile?.id ?? null} + currentLevel={dnsBlocklistProfile?.dns_blocklist ?? null} + /> ); } diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 119bfc2..fe3b680 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -17,6 +17,7 @@ import { LuPuzzle, LuRefreshCw, LuSettings, + LuShield, LuShieldCheck, LuTrash2, LuUsers, @@ -64,6 +65,7 @@ interface ProfileInfoDialogProps { onOpenCookieManagement?: (profile: BrowserProfile) => void; onAssignExtensionGroup?: (profileIds: string[]) => void; onOpenBypassRules?: (profile: BrowserProfile) => void; + onOpenDnsBlocklist?: (profile: BrowserProfile) => void; onCloneProfile?: (profile: BrowserProfile) => void; onDeleteProfile?: (profile: BrowserProfile) => void; onLaunchWithSync?: (profile: BrowserProfile) => void; @@ -110,6 +112,7 @@ export function ProfileInfoDialog({ onOpenCookieManagement, onAssignExtensionGroup, onOpenBypassRules, + onOpenDnsBlocklist, onCloneProfile, onDeleteProfile, onLaunchWithSync, @@ -315,9 +318,8 @@ export function ProfileInfoDialog({ onClick: () => { handleAction(() => onAssignExtensionGroup?.([profile.id])); }, - disabled: isDisabled || !crossOsUnlocked, - proBadge: !crossOsUnlocked, - runningBadge: isRunning && crossOsUnlocked, + disabled: isDisabled, + runningBadge: isRunning, hidden: profile.ephemeral === true, }, { @@ -327,6 +329,13 @@ export function ProfileInfoDialog({ handleAction(() => onOpenBypassRules?.(profile)); }, }, + { + icon: , + label: t("dnsBlocklist.title"), + onClick: () => { + handleAction(() => onOpenDnsBlocklist?.(profile)); + }, + }, { icon: , label: t("profiles.actions.delete"), @@ -455,6 +464,16 @@ export function ProfileInfoDialog({ : t("profileInfo.values.never") } /> +
{/* Sync */} @@ -563,6 +582,91 @@ export function ProfileInfoDialog({ ); } +interface ProfileDnsBlocklistDialogProps { + isOpen: boolean; + onClose: () => void; + profileId: string | null; + currentLevel: string | null; +} + +export function ProfileDnsBlocklistDialog({ + isOpen, + onClose, + profileId, + currentLevel, +}: ProfileDnsBlocklistDialogProps) { + const { t } = useTranslation(); + const [level, setLevel] = React.useState(currentLevel ?? ""); + const [isSaving, setIsSaving] = React.useState(false); + + React.useEffect(() => { + if (isOpen) { + setLevel(currentLevel ?? ""); + } + }, [isOpen, currentLevel]); + + const handleSave = async () => { + if (!profileId) return; + setIsSaving(true); + try { + await invoke("update_profile_dns_blocklist", { + profileId, + dnsBlocklist: level || null, + }); + onClose(); + } catch (err) { + console.error("Failed to update DNS blocklist:", err); + } finally { + setIsSaving(false); + } + }; + + const options = [ + { value: "", label: t("dnsBlocklist.none") }, + { value: "light", label: t("dnsBlocklist.light") }, + { value: "normal", label: t("dnsBlocklist.normal") }, + { value: "pro", label: t("dnsBlocklist.pro") }, + { value: "pro_plus", label: t("dnsBlocklist.proPlus") }, + { value: "ultimate", label: t("dnsBlocklist.ultimate") }, + ]; + + return ( + !open && onClose()}> + + + {t("dnsBlocklist.title")} + +

+ {t("dnsBlocklist.settingsDescription")} +

+
+ {options.map((option) => ( + + ))} +
+ +
+
+ ); +} + interface ProfileBypassRulesDialogProps { isOpen: boolean; onClose: () => void; diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 360c7dc..fd0d959 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -2,11 +2,12 @@ import { invoke } from "@tauri-apps/api/core"; import Color from "color"; -import { useTheme } from "next-themes"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { BsCamera, BsMic } from "react-icons/bs"; +import { DnsBlocklistDialog } from "@/components/dns-blocklist-dialog"; import { LoadingButton } from "@/components/loading-button"; +import { useTheme } from "@/components/theme-provider"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -118,6 +119,7 @@ export function SettingsDialog({ const [requestingPermission, setRequestingPermission] = useState(null); const [isMacOS, setIsMacOS] = useState(false); + const [dnsBlocklistDialogOpen, setDnsBlocklistDialogOpen] = useState(false); const [isLinux, setIsLinux] = useState(false); const [hasE2ePassword, setHasE2ePassword] = useState(false); const [e2ePassword, setE2ePassword] = useState(""); @@ -580,562 +582,595 @@ export function SettingsDialog({ settings.disable_auto_updates !== originalSettings.disable_auto_updates; return ( - - - - {t("settings.title")} - + <> + + + + {t("settings.title")} + -
- {/* Appearance Section */} -
- +
+ {/* Appearance Section */} +
+ -
- - { + updateSetting("theme", value); + if (value === "custom") { + const tokyoNightTheme = getThemeById("tokyo-night"); + if (tokyoNightTheme) { + setCustomThemeState({ + selectedThemeId: "tokyo-night", + colors: tokyoNightTheme.colors, + }); + } } - } - }} - > - - - - - Light - Dark - System - Custom - - + }} + > + + + + + Light + Dark + System + Custom + + +
+ +

+ Choose your preferred theme or follow your system settings. + Custom theme changes are applied only when you save. +

+ + {settings.theme === "custom" && ( +
+
+ + +
+ +
Custom Colors
+
+ {THEME_VARIABLES.map(({ key, label }) => { + const colorValue = + customThemeState.colors[key] ?? "#000000"; + return ( +
+ + +
+ ); + })} +
+
+ )}
-

- Choose your preferred theme or follow your system settings. Custom - theme changes are applied only when you save. -

+ {/* Language Section */} +
+ - {settings.theme === "custom" && ( -
-
-
+ + {/* Default Browser Section - hidden in portable mode */} + {!systemInfo?.portable && ( +
+
+ - + + {isDefaultBrowser ? "Active" : "Inactive"} +
-
Custom Colors
-
- {THEME_VARIABLES.map(({ key, label }) => { - const colorValue = - customThemeState.colors[key] ?? "#000000"; - return ( + { + handleSetDefaultBrowser().catch((err: unknown) => { + console.error(err); + }); + }} + disabled={isDefaultBrowser} + variant={isDefaultBrowser ? "outline" : "default"} + className="w-full" + > + {isDefaultBrowser + ? "Already Default Browser" + : "Set as Default Browser"} + + +

+ When set as default, Donut Browser will handle web links and + allow you to choose which profile to use. +

+
+ )} + + {/* Permissions Section - Only show on macOS */} + {isMacOS && ( +
+ + + {isLoadingPermissions ? ( +
+ Loading permissions... +
+ ) : ( +
+ {permissions.map((permission) => (
- - -
+ ))} +
+ )} + +

+ These permissions allow browsers launched from Donut Browser + to access system resources. Each website will still ask for + your permission individually. +

)} -
- {/* Language Section */} -
- - -
- - -
- -

- Choose your preferred language for the application interface. -

-
- - {/* Default Browser Section - hidden in portable mode */} - {!systemInfo?.portable && ( + {/* Integrations Section */}
-
- - - {isDefaultBrowser ? "Active" : "Inactive"} - -
- - { - handleSetDefaultBrowser().catch((err: unknown) => { - console.error(err); - }); - }} - disabled={isDefaultBrowser} - variant={isDefaultBrowser ? "outline" : "default"} - className="w-full" - > - {isDefaultBrowser - ? "Already Default Browser" - : "Set as Default Browser"} - - +

- When set as default, Donut Browser will handle web links and - allow you to choose which profile to use. + Configure Local API and MCP (Model Context Protocol) for + integrating with external tools and AI assistants.

+ + Open Integrations Settings +
- )} - {/* Permissions Section - Only show on macOS */} - {isMacOS && ( + {/* DNS Blocklist Section */}
+

+ {t("dnsBlocklist.settingsDescription")} +

+ setDnsBlocklistDialogOpen(true)} + > + {t("dnsBlocklist.manageLists")} + +
- {isLoadingPermissions ? ( -
- Loading permissions... + {/* Sync Encryption Section */} +
+ +

+ {t( + "settings.encryption.description", + "Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.", + )} +

+ + {!canUseEncryption ? ( +

+ {t( + "settings.encryption.requiresProOrOwner", + "Profile encryption is available for Pro users and team owners.", + )} +

+ ) : hasE2ePassword ? ( +
+
+ + {t("settings.encryption.passwordSet", "Active")} + + + {t( + "settings.encryption.passwordSetDescription", + "E2E encryption password is set", + )} + +
+
+ + +
) : (
- {permissions.map((permission) => ( -
-
- {getPermissionIcon(permission.permission_type)} -
-
- {getPermissionDisplayName( - permission.permission_type, - )} -
-
- {permission.description} -
-
-
-
- {getStatusBadge(permission.isGranted)} - {!permission.isGranted && ( - { - handleRequestPermission( - permission.permission_type, - ).catch((err: unknown) => { - console.error(err); - }); - }} - > - Grant - - )} -
-
- ))} -
- )} - -

- These permissions allow browsers launched from Donut Browser to - access system resources. Each website will still ask for your - permission individually. -

-
- )} - - {/* Integrations Section */} -
- -

- Configure Local API and MCP (Model Context Protocol) for - integrating with external tools and AI assistants. -

- - Open Integrations Settings - -
- - {/* Sync Encryption Section */} -
- -

- {t( - "settings.encryption.description", - "Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.", - )} -

- - {!canUseEncryption ? ( -

- {t( - "settings.encryption.requiresProOrOwner", - "Profile encryption is available for Pro users and team owners.", - )} -

- ) : hasE2ePassword ? ( -
-
- - {t("settings.encryption.passwordSet", "Active")} - - - {t( - "settings.encryption.passwordSetDescription", - "E2E encryption password is set", + -
-
- - -
-
- ) : ( -
- { - setE2ePassword(e.target.value); - setE2eError(""); - }} - /> - { - setE2ePasswordConfirm(e.target.value); - setE2eError(""); - }} - /> - {e2eError && ( -

{e2eError}

- )} - { - if (e2ePassword.length < 8) { - setE2eError( - t( - "settings.encryption.passwordTooShort", - "Password must be at least 8 characters", - ), - ); - return; - } - if (e2ePassword !== e2ePasswordConfirm) { - setE2eError( - t( - "settings.encryption.passwordMismatch", - "Passwords do not match", - ), - ); - return; - } - setIsSavingE2e(true); - try { - await invoke("set_e2e_password", { - password: e2ePassword, - }); - setHasE2ePassword(true); - setE2ePassword(""); - setE2ePasswordConfirm(""); - showSuccessToast( - t( - "settings.encryption.passwordSaved", - "Encryption password set", - ), - ); - } catch (error) { - showErrorToast(String(error)); - } finally { - setIsSavingE2e(false); - } - }} - > - {t("settings.encryption.setPassword", "Set Password")} - -
- )} -
- - {/* Commercial License Section */} -
- - -
- {trialStatus?.type === "Active" ? ( -
-

- Trial: {trialStatus.days_remaining} days,{" "} - {trialStatus.hours_remaining} hours remaining -

-

- Commercial use is free during the trial period -

-
- ) : ( -
-

- Trial expired -

-

- Personal use remains free. Commercial use requires a - license. -

+ {t("settings.encryption.setPassword", "Set Password")} +
)}
-
- {/* Advanced Section */} -
- + {/* Commercial License Section */} +
+ - {!isLinux && ( -
- { - updateSetting("disable_auto_updates", checked as boolean); - }} - /> -
- -

- {t("settings.disableAutoUpdatesDescription")} -

+
+ {trialStatus?.type === "Active" ? ( +
+

+ Trial: {trialStatus.days_remaining} days,{" "} + {trialStatus.hours_remaining} hours remaining +

+

+ Commercial use is free during the trial period +

+
+ ) : ( +
+

+ Trial expired +

+

+ Personal use remains free. Commercial use requires a + license. +

+
+ )} +
+
+ + {/* Advanced Section */} +
+ + + {!isLinux && ( +
+ { + updateSetting("disable_auto_updates", checked as boolean); + }} + /> +
+ +

+ {t("settings.disableAutoUpdatesDescription")} +

+
+ )} + + { + handleClearCache().catch((err: unknown) => { + console.error(err); + }); + }} + variant="outline" + className="w-full" + > + Clear All Version Cache + + +

+ Clear all cached browser version data and refresh all browser + versions from their sources. This will force a fresh download of + version information for all browsers. +

+
+ + {/* System Info */} + {systemInfo && ( +
+

+ {`Donut Browser ${systemInfo.app_version}\n${systemInfo.os} ${systemInfo.arch}${systemInfo.portable ? " (portable)" : ""}`} +

)} +
+ + + Cancel + { - handleClearCache().catch((err: unknown) => { + handleSave().catch((err: unknown) => { console.error(err); }); }} - variant="outline" - className="w-full" + disabled={isLoading || !hasChanges} > - Clear All Version Cache + Save Settings - -

- Clear all cached browser version data and refresh all browser - versions from their sources. This will force a fresh download of - version information for all browsers. -

-
- - {/* System Info */} - {systemInfo && ( -
-

- {`Donut Browser ${systemInfo.app_version}\n${systemInfo.os} ${systemInfo.arch}${systemInfo.portable ? " (portable)" : ""}`} -

-
- )} -
- - - - Cancel - - { - handleSave().catch((err: unknown) => { - console.error(err); - }); - }} - disabled={isLoading || !hasChanges} - > - Save Settings - - - -
+ +
+
+ setDnsBlocklistDialogOpen(false)} + /> + ); } diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index 83cc1b8..e1b7543 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -1,7 +1,13 @@ "use client"; -import { ThemeProvider } from "next-themes"; -import { useEffect, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; import { applyThemeColors, clearThemeColors } from "@/lib/themes"; interface AppSettings { @@ -10,43 +16,62 @@ interface AppSettings { custom_theme?: Record; } +interface ThemeContextValue { + theme: string; + setTheme: (theme: string) => void; +} + +const ThemeContext = createContext({ + theme: "system", + setTheme: () => {}, +}); + +export function useTheme() { + return useContext(ThemeContext); +} + interface CustomThemeProviderProps { children: React.ReactNode; } +function resolveSystemTheme(): "light" | "dark" { + if (typeof window === "undefined") return "dark"; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function applyClassToHtml(theme: string) { + const resolved = theme === "system" ? resolveSystemTheme() : theme; + const root = document.documentElement; + root.classList.remove("light", "dark"); + root.classList.add(resolved); +} + export function CustomThemeProvider({ children }: CustomThemeProviderProps) { const [isLoading, setIsLoading] = useState(true); - const [defaultTheme, setDefaultTheme] = useState("system"); - const [_mounted, setMounted] = useState(false); + const [theme, setThemeState] = useState("system"); - useEffect(() => { - setMounted(true); + const setTheme = useCallback((newTheme: string) => { + setThemeState(newTheme); + if (newTheme === "custom") { + applyClassToHtml("dark"); + } else { + applyClassToHtml(newTheme); + } }, []); + // Load initial theme from Tauri settings useEffect(() => { const loadTheme = async () => { try { - // Lazy import to avoid pulling Tauri API on SSR const { invoke } = await import("@tauri-apps/api/core"); const settings = await invoke("get_app_settings"); const themeValue = settings?.theme ?? "system"; - console.log("[theme-provider] Loaded settings:", { - theme: themeValue, - hasCustomTheme: !!settings?.custom_theme, - customThemeKeys: settings?.custom_theme - ? Object.keys(settings.custom_theme).length - : 0, - }); - - if ( - themeValue === "light" || - themeValue === "dark" || - themeValue === "system" - ) { - setDefaultTheme(themeValue); - } else if (themeValue === "custom") { - setDefaultTheme("dark"); + if (themeValue === "custom") { + setThemeState("custom"); + applyClassToHtml("dark"); if ( settings.custom_theme && Object.keys(settings.custom_theme).length > 0 @@ -57,16 +82,22 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) { console.warn("Failed to apply custom theme variables:", error); } } + } else if ( + themeValue === "light" || + themeValue === "dark" || + themeValue === "system" + ) { + setThemeState(themeValue); + applyClassToHtml(themeValue); } else { - setDefaultTheme("system"); + applyClassToHtml("system"); } } catch (error) { - // Failed to load settings; fall back to system (handled by next-themes) console.warn( "Failed to load theme settings; defaulting to system:", error, ); - setDefaultTheme("system"); + applyClassToHtml("system"); } finally { setIsLoading(false); } @@ -75,44 +106,44 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) { void loadTheme(); }, []); - // Additional effect to ensure custom theme is applied after mount + // Re-apply custom theme after mount useEffect(() => { - if (!isLoading && _mounted) { + if (!isLoading && theme === "custom") { const reapplyCustomTheme = async () => { try { const { invoke } = await import("@tauri-apps/api/core"); const settings = await invoke("get_app_settings"); - if (settings?.theme === "custom" && settings.custom_theme) { applyThemeColors(settings.custom_theme); - } else { - clearThemeColors(); } } catch (error) { console.warn("Failed to reapply custom theme:", error); } }; - - // Apply after a short delay to ensure CSS has loaded setTimeout(() => { void reapplyCustomTheme(); }, 100); + } else if (!isLoading) { + clearThemeColors(); } - }, [isLoading, _mounted]); + }, [isLoading, theme]); + + // Listen for system theme changes when in "system" mode + useEffect(() => { + if (theme !== "system") return; + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = () => applyClassToHtml("system"); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [theme]); + + const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]); if (isLoading) { - // Keep UI simple during initial settings load to avoid flicker return null; } return ( - - {children} - + {children} ); } diff --git a/src/components/traffic-details-dialog.tsx b/src/components/traffic-details-dialog.tsx index 6a1dcb1..6a61d84 100644 --- a/src/components/traffic-details-dialog.tsx +++ b/src/components/traffic-details-dialog.tsx @@ -295,7 +295,12 @@ export function TrafficDetailsDialog({
- + - + {children}
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index ce54483..7003e7d 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,7 +1,7 @@ "use client"; -import { useTheme } from "next-themes"; import { Toaster as Sonner, type ToasterProps } from "sonner"; +import { useTheme } from "@/components/theme-provider"; const Toaster = ({ ...props }: ToasterProps) => { const { theme = "system" } = useTheme(); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 246a58e..67b3eb2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -858,5 +858,22 @@ "cookieImportLocked": "Cookie import is a Pro feature", "cookieExportLocked": "Cookie export is a Pro feature", "cookieManagementLocked": "Cookie management is a Pro feature" + }, + "dnsBlocklist": { + "title": "DNS Blocklist", + "none": "None", + "light": "Light", + "normal": "Normal", + "pro": "Pro", + "proPlus": "Pro++", + "ultimate": "Ultimate", + "settingsDescription": "DNS blocklists block ads, trackers, and malware domains at the proxy level. Lists are automatically refreshed every 12 hours.", + "manageLists": "Manage DNS Blocklists", + "refreshAll": "Refresh All Lists", + "refreshFailed": "Failed to refresh DNS blocklists", + "domains": "domains", + "fresh": "Fresh", + "stale": "Stale", + "notCached": "Not cached" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3cd58f8..d6230f9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -858,5 +858,22 @@ "cookieImportLocked": "La importación de cookies es una función Pro", "cookieExportLocked": "La exportación de cookies es una función Pro", "cookieManagementLocked": "La gestión de cookies es una función Pro" + }, + "dnsBlocklist": { + "title": "Lista de bloqueo DNS", + "none": "Ninguno", + "light": "Light", + "normal": "Normal", + "pro": "Pro", + "proPlus": "Pro++", + "ultimate": "Ultimate", + "settingsDescription": "Las listas de bloqueo DNS bloquean anuncios, rastreadores y dominios de malware a nivel de proxy. Las listas se actualizan automáticamente cada 12 horas.", + "manageLists": "Gestionar listas de bloqueo DNS", + "refreshAll": "Actualizar todas las listas", + "refreshFailed": "Error al actualizar las listas de bloqueo DNS", + "domains": "dominios", + "fresh": "Actualizado", + "stale": "Desactualizado", + "notCached": "Sin caché" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 34f58c2..e74fa4e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -858,5 +858,22 @@ "cookieImportLocked": "L'importation de cookies est une fonctionnalité Pro", "cookieExportLocked": "L'exportation de cookies est une fonctionnalité Pro", "cookieManagementLocked": "La gestion des cookies est une fonctionnalité Pro" + }, + "dnsBlocklist": { + "title": "Liste de blocage DNS", + "none": "Aucun", + "light": "Light", + "normal": "Normal", + "pro": "Pro", + "proPlus": "Pro++", + "ultimate": "Ultimate", + "settingsDescription": "Les listes de blocage DNS bloquent les publicités, les traqueurs et les domaines malveillants au niveau du proxy. Les listes sont automatiquement rafraîchies toutes les 12 heures.", + "manageLists": "Gérer les listes de blocage DNS", + "refreshAll": "Rafraîchir toutes les listes", + "refreshFailed": "Échec du rafraîchissement des listes de blocage DNS", + "domains": "domaines", + "fresh": "À jour", + "stale": "Obsolète", + "notCached": "Non mis en cache" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index ceacea3..7081525 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -858,5 +858,22 @@ "cookieImportLocked": "Cookieのインポートはプロ機能です", "cookieExportLocked": "Cookieのエクスポートはプロ機能です", "cookieManagementLocked": "Cookie管理はプロ機能です" + }, + "dnsBlocklist": { + "title": "DNSブロックリスト", + "none": "なし", + "light": "Light", + "normal": "Normal", + "pro": "Pro", + "proPlus": "Pro++", + "ultimate": "Ultimate", + "settingsDescription": "DNSブロックリストは、プロキシレベルで広告、トラッカー、マルウェアドメインをブロックします。リストは12時間ごとに自動的に更新されます。", + "manageLists": "DNSブロックリストを管理", + "refreshAll": "すべてのリストを更新", + "refreshFailed": "DNSブロックリストの更新に失敗しました", + "domains": "ドメイン", + "fresh": "最新", + "stale": "期限切れ", + "notCached": "キャッシュなし" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 5ee1630..de674a6 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -858,5 +858,22 @@ "cookieImportLocked": "A importação de cookies é um recurso Pro", "cookieExportLocked": "A exportação de cookies é um recurso Pro", "cookieManagementLocked": "O gerenciamento de cookies é um recurso Pro" + }, + "dnsBlocklist": { + "title": "Lista de bloqueio DNS", + "none": "Nenhum", + "light": "Light", + "normal": "Normal", + "pro": "Pro", + "proPlus": "Pro++", + "ultimate": "Ultimate", + "settingsDescription": "As listas de bloqueio DNS bloqueiam anúncios, rastreadores e domínios de malware no nível do proxy. As listas são atualizadas automaticamente a cada 12 horas.", + "manageLists": "Gerenciar listas de bloqueio DNS", + "refreshAll": "Atualizar todas as listas", + "refreshFailed": "Falha ao atualizar as listas de bloqueio DNS", + "domains": "domínios", + "fresh": "Atualizado", + "stale": "Desatualizado", + "notCached": "Sem cache" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ca704e7..84bfe4e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -858,5 +858,22 @@ "cookieImportLocked": "Импорт cookies — функция Pro", "cookieExportLocked": "Экспорт cookies — функция Pro", "cookieManagementLocked": "Управление cookies — функция Pro" + }, + "dnsBlocklist": { + "title": "Список блокировки DNS", + "none": "Нет", + "light": "Light", + "normal": "Normal", + "pro": "Pro", + "proPlus": "Pro++", + "ultimate": "Ultimate", + "settingsDescription": "Списки блокировки DNS блокируют рекламу, трекеры и вредоносные домены на уровне прокси. Списки автоматически обновляются каждые 12 часов.", + "manageLists": "Управление списками блокировки DNS", + "refreshAll": "Обновить все списки", + "refreshFailed": "Не удалось обновить списки блокировки DNS", + "domains": "доменов", + "fresh": "Актуальный", + "stale": "Устаревший", + "notCached": "Не кэшировано" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ca6ae2e..312d4ef 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -858,5 +858,22 @@ "cookieImportLocked": "Cookie 导入是 Pro 功能", "cookieExportLocked": "Cookie 导出是 Pro 功能", "cookieManagementLocked": "Cookie 管理是 Pro 功能" + }, + "dnsBlocklist": { + "title": "DNS 拦截列表", + "none": "无", + "light": "Light", + "normal": "Normal", + "pro": "Pro", + "proPlus": "Pro++", + "ultimate": "Ultimate", + "settingsDescription": "DNS 拦截列表在代理级别拦截广告、跟踪器和恶意软件域名。列表每 12 小时自动刷新一次。", + "manageLists": "管理 DNS 拦截列表", + "refreshAll": "刷新所有列表", + "refreshFailed": "刷新 DNS 拦截列表失败", + "domains": "个域名", + "fresh": "最新", + "stale": "过期", + "notCached": "未缓存" } } diff --git a/src/types.ts b/src/types.ts index 258c9af..2cc1d43 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,7 @@ export interface BrowserProfile { proxy_bypass_rules?: string[]; created_by_id?: string; created_by_email?: string; + dns_blocklist?: string; } export interface Extension {