mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-30 07:37:51 +02:00
feat: add proxy check button
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.2",
|
||||
"flag-icons": "^7.5.0",
|
||||
"motion": "^12.23.24",
|
||||
"next": "^15.5.6",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
Generated
+8
@@ -77,6 +77,9 @@ importers:
|
||||
color:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
flag-icons:
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.0
|
||||
motion:
|
||||
specifier: ^12.23.24
|
||||
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -2117,6 +2120,9 @@ packages:
|
||||
resolution: {integrity: sha512-nynXZnqCBtBbEgqqdHS5mGm+R9JRRJxNun+lpZlCxGVt0BzgQJGibOvYCe5I54hIIVsaTldZ+jOb4btRPfPD6g==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
flag-icons@7.5.0:
|
||||
resolution: {integrity: sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==}
|
||||
|
||||
framer-motion@12.23.24:
|
||||
resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==}
|
||||
peerDependencies:
|
||||
@@ -4756,6 +4762,8 @@ snapshots:
|
||||
header-generator: 2.1.76
|
||||
tslib: 2.8.1
|
||||
|
||||
flag-icons@7.5.0: {}
|
||||
|
||||
framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
motion-dom: 12.23.23
|
||||
|
||||
Generated
+7
@@ -1125,6 +1125,7 @@ dependencies = [
|
||||
"tower",
|
||||
"tower-http",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"windows 0.62.2",
|
||||
"winreg",
|
||||
@@ -5487,6 +5488,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -48,6 +48,7 @@ msi-extract = "0"
|
||||
|
||||
uuid = { version = "1.18", features = ["v4", "serde"] }
|
||||
url = "2.5"
|
||||
urlencoding = "2.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
axum = "0.8.4"
|
||||
tower = "0.5"
|
||||
|
||||
@@ -221,6 +221,21 @@ async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) ->
|
||||
.map_err(|e| format!("Failed to delete stored proxy: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_proxy_validity(
|
||||
proxy_id: String,
|
||||
proxy_settings: crate::browser::ProxySettings,
|
||||
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.check_proxy_validity(&proxy_id, &proxy_settings)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_cached_proxy_check(proxy_id: String) -> Option<crate::proxy_manager::ProxyCheckResult> {
|
||||
crate::proxy_manager::PROXY_MANAGER.get_cached_proxy_check(&proxy_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn is_geoip_database_available() -> Result<bool, String> {
|
||||
Ok(GeoIPDownloader::is_geoip_database_available())
|
||||
@@ -682,6 +697,8 @@ pub fn run() {
|
||||
get_stored_proxies,
|
||||
update_stored_proxy,
|
||||
delete_stored_proxy,
|
||||
check_proxy_validity,
|
||||
get_cached_proxy_check,
|
||||
update_camoufox_config,
|
||||
get_profile_groups,
|
||||
get_groups_with_profile_counts,
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
@@ -23,6 +24,17 @@ pub struct ProxyInfo {
|
||||
pub profile_name: Option<String>,
|
||||
}
|
||||
|
||||
// Proxy check result cache
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProxyCheckResult {
|
||||
pub ip: String,
|
||||
pub city: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub country_code: Option<String>,
|
||||
pub timestamp: u64,
|
||||
pub is_valid: bool,
|
||||
}
|
||||
|
||||
// Stored proxy configuration with name and ID for reuse
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredProxy {
|
||||
@@ -91,6 +103,115 @@ impl ProxyManager {
|
||||
path
|
||||
}
|
||||
|
||||
// Get the path to the proxy check cache directory
|
||||
fn get_proxy_check_cache_dir(&self) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let mut path = self.base_dirs.cache_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("proxy_checks");
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
// Get the path to a specific proxy check cache file
|
||||
fn get_proxy_check_cache_file(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let cache_dir = self.get_proxy_check_cache_dir()?;
|
||||
Ok(cache_dir.join(format!("{proxy_id}.json")))
|
||||
}
|
||||
|
||||
// Load cached proxy check result
|
||||
fn load_proxy_check_cache(&self, proxy_id: &str) -> Option<ProxyCheckResult> {
|
||||
let cache_file = match self.get_proxy_check_cache_file(proxy_id) {
|
||||
Ok(file) => file,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
if !cache_file.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&cache_file) {
|
||||
Ok(content) => content,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
serde_json::from_str::<ProxyCheckResult>(&content).ok()
|
||||
}
|
||||
|
||||
// Save proxy check result to cache
|
||||
fn save_proxy_check_cache(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
result: &ProxyCheckResult,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cache_file = self.get_proxy_check_cache_file(proxy_id)?;
|
||||
let content = serde_json::to_string_pretty(result)?;
|
||||
fs::write(&cache_file, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Get current timestamp
|
||||
fn get_current_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
// Get geolocation for an IP address
|
||||
async fn get_ip_geolocation(
|
||||
ip: &str,
|
||||
) -> Result<(Option<String>, Option<String>, Option<String>), String> {
|
||||
// Use ip-api.com (free, no API key required)
|
||||
let url = format!(
|
||||
"http://ip-api.com/json/{}?fields=status,message,country,countryCode,city",
|
||||
ip
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
match response.json::<serde_json::Value>().await {
|
||||
Ok(json) => {
|
||||
if json.get("status").and_then(|s| s.as_str()) == Some("success") {
|
||||
let country = json
|
||||
.get("country")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let country_code = json
|
||||
.get("countryCode")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let city = json
|
||||
.get("city")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
Ok((city, country, country_code))
|
||||
} else {
|
||||
Ok((None, None, None))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Failed to parse geolocation response: {e}")),
|
||||
}
|
||||
} else {
|
||||
Ok((None, None, None))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Failed to fetch geolocation: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Get the path to a specific proxy file
|
||||
fn get_proxy_file_path(&self, proxy_id: &str) -> PathBuf {
|
||||
self.get_proxies_dir().join(format!("{proxy_id}.json"))
|
||||
@@ -278,6 +399,159 @@ impl ProxyManager {
|
||||
.map(|p| p.proxy_settings.clone())
|
||||
}
|
||||
|
||||
// Build proxy URL string from ProxySettings
|
||||
fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
|
||||
let mut url = format!("{}://", proxy_settings.proxy_type);
|
||||
|
||||
if let (Some(username), Some(password)) = (&proxy_settings.username, &proxy_settings.password) {
|
||||
url.push_str(&urlencoding::encode(username));
|
||||
url.push(':');
|
||||
url.push_str(&urlencoding::encode(password));
|
||||
url.push('@');
|
||||
} else if let Some(username) = &proxy_settings.username {
|
||||
url.push_str(&urlencoding::encode(username));
|
||||
url.push('@');
|
||||
}
|
||||
|
||||
url.push_str(&proxy_settings.host);
|
||||
url.push(':');
|
||||
url.push_str(&proxy_settings.port.to_string());
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
// Validate IP address (IPv4 or IPv6)
|
||||
fn validate_ip(ip: &str) -> bool {
|
||||
// IPv4 validation
|
||||
if ip.matches('.').count() == 3 {
|
||||
let parts: Vec<&str> = ip.split('.').collect();
|
||||
if parts.len() == 4 {
|
||||
return parts.iter().all(|part| part.parse::<u8>().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
// IPv6 validation (simplified - checks for colons and hex digits)
|
||||
if ip.matches(':').count() >= 2 {
|
||||
let parts: Vec<&str> = ip.split(':').collect();
|
||||
return parts
|
||||
.iter()
|
||||
.all(|part| part.is_empty() || part.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// Check if a proxy is valid by making HTTP requests through it
|
||||
pub async fn check_proxy_validity(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
proxy_settings: &ProxySettings,
|
||||
) -> Result<ProxyCheckResult, String> {
|
||||
let proxy_url = Self::build_proxy_url(proxy_settings);
|
||||
|
||||
// List of IP check endpoints to try
|
||||
let ip_check_urls = vec![
|
||||
"https://api.ipify.org",
|
||||
"https://checkip.amazonaws.com",
|
||||
"https://ipinfo.io/ip",
|
||||
"https://icanhazip.com",
|
||||
"https://ifconfig.co/ip",
|
||||
"https://ipecho.net/plain",
|
||||
];
|
||||
|
||||
// Create HTTP client with proxy
|
||||
// reqwest::Proxy::all expects http/https URLs, but we need to handle socks proxies differently
|
||||
let proxy = match proxy_settings.proxy_type.as_str() {
|
||||
"socks4" | "socks5" => {
|
||||
// For SOCKS proxies, reqwest doesn't support them directly via Proxy::all
|
||||
// We'll need to use a different approach or return an error
|
||||
return Err("SOCKS proxy validation not yet supported".to_string());
|
||||
}
|
||||
_ => reqwest::Proxy::all(&proxy_url).map_err(|e| format!("Failed to create proxy: {e}"))?,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
|
||||
// Try each endpoint until one succeeds
|
||||
let mut last_error = None;
|
||||
let mut ip: Option<String> = None;
|
||||
|
||||
for url_str in ip_check_urls {
|
||||
match client.get(url_str).send().await {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
match response.text().await {
|
||||
Ok(ip_text) => {
|
||||
let ip_str = ip_text.trim();
|
||||
if Self::validate_ip(ip_str) {
|
||||
ip = Some(ip_str.to_string());
|
||||
break;
|
||||
} else {
|
||||
last_error = Some(format!("Invalid IP address returned: {ip_str}"));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(format!("Failed to read response from {url_str}: {e}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
last_error = Some(format!("HTTP error from {url_str}: {}", response.status()));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(format!("Request to {url_str} failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ip = match ip {
|
||||
Some(ip) => ip,
|
||||
None => {
|
||||
// Save failed check result
|
||||
let failed_result = ProxyCheckResult {
|
||||
ip: String::new(),
|
||||
city: None,
|
||||
country: None,
|
||||
country_code: None,
|
||||
timestamp: Self::get_current_timestamp(),
|
||||
is_valid: false,
|
||||
};
|
||||
let _ = self.save_proxy_check_cache(proxy_id, &failed_result);
|
||||
return Err(
|
||||
last_error.unwrap_or_else(|| "Failed to get public IP from any endpoint".to_string()),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Get geolocation
|
||||
let (city, country, country_code): (Option<String>, Option<String>, Option<String>) =
|
||||
Self::get_ip_geolocation(&ip).await.unwrap_or_default();
|
||||
|
||||
// Create successful result
|
||||
let result = ProxyCheckResult {
|
||||
ip: ip.clone(),
|
||||
city,
|
||||
country,
|
||||
country_code,
|
||||
timestamp: Self::get_current_timestamp(),
|
||||
is_valid: true,
|
||||
};
|
||||
|
||||
// Save to cache
|
||||
let _ = self.save_proxy_check_cache(proxy_id, &result);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// Get cached proxy check result
|
||||
pub fn get_cached_proxy_check(&self, proxy_id: &str) -> Option<ProxyCheckResult> {
|
||||
self.load_proxy_check_cache(proxy_id)
|
||||
}
|
||||
|
||||
// Start a proxy for given proxy settings and associate it with a browser process ID
|
||||
// If proxy_settings is None, starts a direct proxy for traffic monitoring
|
||||
pub async fn start_proxy(
|
||||
|
||||
@@ -1,6 +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 { CustomThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getFlagIconClass } from "@/lib/flag-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FlagIconProps {
|
||||
countryCode?: string;
|
||||
className?: string;
|
||||
squared?: boolean;
|
||||
}
|
||||
|
||||
export function FlagIcon({
|
||||
countryCode,
|
||||
className,
|
||||
squared = false,
|
||||
}: FlagIconProps) {
|
||||
if (!countryCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const flagClass = getFlagIconClass(countryCode);
|
||||
if (!flagClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <span className={cn(flagClass, squared && "fis", className)} />;
|
||||
}
|
||||
@@ -61,9 +61,10 @@ import {
|
||||
} from "@/lib/browser-utils";
|
||||
import { trimName } from "@/lib/name-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserProfile, StoredProxy } from "@/types";
|
||||
import type { BrowserProfile, ProxyCheckResult, StoredProxy } from "@/types";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
import MultipleSelector, { type Option } from "./multiple-selector";
|
||||
import { ProxyCheckButton } from "./proxy-check-button";
|
||||
import { Input } from "./ui/input";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -99,6 +100,8 @@ type TableMeta = {
|
||||
profileId: string,
|
||||
proxyId: string | null,
|
||||
) => void | Promise<void>;
|
||||
checkingProxyId: string | null;
|
||||
proxyCheckResults: Record<string, ProxyCheckResult>;
|
||||
|
||||
// Selection helpers
|
||||
isProfileSelected: (id: string) => boolean;
|
||||
@@ -437,6 +440,42 @@ export function ProfilesDataTable({
|
||||
const [openProxySelectorFor, setOpenProxySelectorFor] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [checkingProxyId, setCheckingProxyId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [proxyCheckResults, setProxyCheckResults] = React.useState<
|
||||
Record<string, ProxyCheckResult>
|
||||
>({});
|
||||
|
||||
// Load cached check results for proxies
|
||||
React.useEffect(() => {
|
||||
const loadCachedResults = async () => {
|
||||
const results: Record<string, ProxyCheckResult> = {};
|
||||
const proxyIds = new Set<string>();
|
||||
for (const profile of profiles) {
|
||||
if (profile.proxy_id) {
|
||||
proxyIds.add(profile.proxy_id);
|
||||
}
|
||||
}
|
||||
for (const proxyId of proxyIds) {
|
||||
try {
|
||||
const cached = await invoke<ProxyCheckResult | null>(
|
||||
"get_cached_proxy_check",
|
||||
{ proxyId },
|
||||
);
|
||||
if (cached) {
|
||||
results[proxyId] = cached;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
setProxyCheckResults(results);
|
||||
};
|
||||
if (profiles.length > 0) {
|
||||
void loadCachedResults();
|
||||
}
|
||||
}, [profiles]);
|
||||
|
||||
const loadAllTags = React.useCallback(async () => {
|
||||
try {
|
||||
@@ -779,6 +818,8 @@ export function ProfilesDataTable({
|
||||
proxyOverrides,
|
||||
storedProxies,
|
||||
handleProxySelection,
|
||||
checkingProxyId,
|
||||
proxyCheckResults,
|
||||
|
||||
// Selection helpers
|
||||
isProfileSelected: (id: string) => selectedProfiles.includes(id),
|
||||
@@ -823,6 +864,8 @@ export function ProfilesDataTable({
|
||||
proxyOverrides,
|
||||
storedProxies,
|
||||
handleProxySelection,
|
||||
checkingProxyId,
|
||||
proxyCheckResults,
|
||||
handleToggleAll,
|
||||
handleCheckboxChange,
|
||||
handleIconClick,
|
||||
@@ -1275,90 +1318,114 @@ export function ProfilesDataTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isSelectorOpen}
|
||||
onOpenChange={(open) =>
|
||||
meta.setOpenProxySelectorFor(open ? profile.id : null)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"flex gap-2 items-center p-2 rounded",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed pointer-events-none"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Popover
|
||||
open={isSelectorOpen}
|
||||
onOpenChange={(open) =>
|
||||
meta.setOpenProxySelectorFor(open ? profile.id : null)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
!profileHasProxy && "text-muted-foreground",
|
||||
"flex gap-2 items-center p-2 rounded",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed pointer-events-none"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{profileHasProxy
|
||||
? trimName(displayName, 10)
|
||||
: displayName}
|
||||
</span>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{tooltipText && <TooltipContent>{tooltipText}</TooltipContent>}
|
||||
</Tooltip>
|
||||
|
||||
{!isDisabled && (
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() =>
|
||||
void meta.handleProxySelection(profile.id, null)
|
||||
}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
!profileHasProxy && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveProxyId === null
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
No Proxy
|
||||
</CommandItem>
|
||||
{meta.storedProxies.map((proxy) => (
|
||||
{profileHasProxy
|
||||
? trimName(displayName, 10)
|
||||
: displayName}
|
||||
</span>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{tooltipText && (
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{!isDisabled && (
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
value="__none__"
|
||||
onSelect={() =>
|
||||
void meta.handleProxySelection(
|
||||
profile.id,
|
||||
proxy.id,
|
||||
)
|
||||
void meta.handleProxySelection(profile.id, null)
|
||||
}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveProxyId === proxy.id
|
||||
effectiveProxyId === null
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
No Proxy
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
{meta.storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() =>
|
||||
void meta.handleProxySelection(
|
||||
profile.id,
|
||||
proxy.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveProxyId === proxy.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
{profileHasProxy && effectiveProxy && !isDisabled && (
|
||||
<ProxyCheckButton
|
||||
proxy={effectiveProxy}
|
||||
checkingProxyId={meta.checkingProxyId}
|
||||
cachedResult={meta.proxyCheckResults[effectiveProxy.id]}
|
||||
setCheckingProxyId={setCheckingProxyId}
|
||||
onCheckComplete={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[effectiveProxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
onCheckFailed={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[effectiveProxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { FlagIcon } from "@/components/flag-icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import type { ProxyCheckResult, StoredProxy } from "@/types";
|
||||
|
||||
interface ProxyCheckButtonProps {
|
||||
proxy: StoredProxy;
|
||||
checkingProxyId: string | null;
|
||||
cachedResult?: ProxyCheckResult;
|
||||
onCheckComplete?: (result: ProxyCheckResult) => void;
|
||||
onCheckFailed?: (result: ProxyCheckResult) => void;
|
||||
disabled?: boolean;
|
||||
setCheckingProxyId?: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export function ProxyCheckButton({
|
||||
proxy,
|
||||
checkingProxyId,
|
||||
cachedResult,
|
||||
onCheckComplete,
|
||||
onCheckFailed,
|
||||
disabled = false,
|
||||
setCheckingProxyId,
|
||||
}: ProxyCheckButtonProps) {
|
||||
const [localResult, setLocalResult] = React.useState<
|
||||
ProxyCheckResult | undefined
|
||||
>(cachedResult);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalResult(cachedResult);
|
||||
}, [cachedResult]);
|
||||
|
||||
const handleCheck = React.useCallback(async () => {
|
||||
if (checkingProxyId === proxy.id) return;
|
||||
|
||||
setCheckingProxyId?.(proxy.id);
|
||||
try {
|
||||
const result = await invoke<ProxyCheckResult>("check_proxy_validity", {
|
||||
proxyId: proxy.id,
|
||||
proxySettings: proxy.proxy_settings,
|
||||
});
|
||||
setLocalResult(result);
|
||||
onCheckComplete?.(result);
|
||||
|
||||
// Show toast with location
|
||||
const locationParts: string[] = [];
|
||||
if (result.city) locationParts.push(result.city);
|
||||
if (result.country) locationParts.push(result.country);
|
||||
const location =
|
||||
locationParts.length > 0 ? locationParts.join(", ") : "Unknown";
|
||||
|
||||
toast.success(
|
||||
<div className="flex items-center gap-2">
|
||||
Your proxy location is:
|
||||
<span>{location}</span>
|
||||
{result.country_code && (
|
||||
<FlagIcon countryCode={result.country_code} className="text-base" />
|
||||
)}
|
||||
</div>,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Proxy check failed: ${errorMessage}`);
|
||||
|
||||
// Save failed check result
|
||||
const failedResult: ProxyCheckResult = {
|
||||
ip: "",
|
||||
city: undefined,
|
||||
country: undefined,
|
||||
country_code: undefined,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
is_valid: false,
|
||||
};
|
||||
setLocalResult(failedResult);
|
||||
onCheckFailed?.(failedResult);
|
||||
} finally {
|
||||
setCheckingProxyId?.(null);
|
||||
}
|
||||
}, [
|
||||
proxy,
|
||||
checkingProxyId,
|
||||
onCheckComplete,
|
||||
onCheckFailed,
|
||||
setCheckingProxyId,
|
||||
]);
|
||||
|
||||
const isCurrentlyChecking = checkingProxyId === proxy.id;
|
||||
const result = localResult;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleCheck}
|
||||
disabled={isCurrentlyChecking || disabled}
|
||||
>
|
||||
{isCurrentlyChecking ? (
|
||||
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
) : result?.is_valid && result.country_code ? (
|
||||
<span className="relative inline-flex items-center justify-center">
|
||||
<FlagIcon countryCode={result.country_code} className="h-2.5" />
|
||||
<FiCheck className="absolute bottom-[-6px] right-[-4px]" />
|
||||
</span>
|
||||
) : result && !result.is_valid ? (
|
||||
<span className="text-red-600 text-sm">✕</span>
|
||||
) : (
|
||||
<FiCheck className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentlyChecking ? (
|
||||
<p>Checking proxy...</p>
|
||||
) : result?.is_valid ? (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1">
|
||||
{result.country_code && (
|
||||
<FlagIcon countryCode={result.country_code} />
|
||||
)}
|
||||
{[result.city, result.country].filter(Boolean).join(", ") ||
|
||||
"Unknown"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">IP: {result.ip}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
) : result && !result.is_valid ? (
|
||||
<div>
|
||||
<p>Proxy check failed</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Failed {formatRelativeTime(result.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Check proxy validity</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import * as React from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { FiEdit2, FiPlus, FiTrash2, FiWifi } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
@@ -24,7 +25,8 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { trimName } from "@/lib/name-utils";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import type { ProxyCheckResult, StoredProxy } from "@/types";
|
||||
import { ProxyCheckButton } from "./proxy-check-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProxyManagementDialogProps {
|
||||
@@ -40,9 +42,37 @@ export function ProxyManagementDialog({
|
||||
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
|
||||
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [checkingProxyId, setCheckingProxyId] = useState<string | null>(null);
|
||||
const [proxyCheckResults, setProxyCheckResults] = useState<
|
||||
Record<string, ProxyCheckResult>
|
||||
>({});
|
||||
|
||||
const { storedProxies, proxyUsage, isLoading } = useProxyEvents();
|
||||
|
||||
// Load cached check results on mount and when proxies change
|
||||
React.useEffect(() => {
|
||||
const loadCachedResults = async () => {
|
||||
const results: Record<string, ProxyCheckResult> = {};
|
||||
for (const proxy of storedProxies) {
|
||||
try {
|
||||
const cached = await invoke<ProxyCheckResult | null>(
|
||||
"get_cached_proxy_check",
|
||||
{ proxyId: proxy.id },
|
||||
);
|
||||
if (cached) {
|
||||
results[proxy.id] = cached;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
setProxyCheckResults(results);
|
||||
};
|
||||
if (storedProxies.length > 0) {
|
||||
void loadCachedResults();
|
||||
}
|
||||
}, [storedProxies]);
|
||||
|
||||
const handleDeleteProxy = useCallback((proxy: StoredProxy) => {
|
||||
// Open in-app confirmation dialog
|
||||
setProxyToDelete(proxy);
|
||||
@@ -163,7 +193,25 @@ export function ProxyManagementDialog({
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 gap-1 items-center">
|
||||
<div className="flex shrink-0 gap-1 items-center">
|
||||
<ProxyCheckButton
|
||||
proxy={proxy}
|
||||
checkingProxyId={checkingProxyId}
|
||||
cachedResult={proxyCheckResults[proxy.id]}
|
||||
setCheckingProxyId={setCheckingProxyId}
|
||||
onCheckComplete={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
onCheckFailed={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Get flag icon CSS class for a country code (ISO 3166-1 alpha-2)
|
||||
*/
|
||||
export function getFlagIconClass(countryCode: string): string {
|
||||
if (!countryCode || countryCode.length !== 2) {
|
||||
return "";
|
||||
}
|
||||
return `fi fi-${countryCode.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 minutes ago", "1 hour ago")
|
||||
*/
|
||||
export function formatRelativeTime(timestamp: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const secondsAgo = now - timestamp;
|
||||
|
||||
if (secondsAgo < 60) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
const minutesAgo = Math.floor(secondsAgo / 60);
|
||||
if (minutesAgo < 60) {
|
||||
return `${minutesAgo} minute${minutesAgo !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
|
||||
const hoursAgo = Math.floor(minutesAgo / 60);
|
||||
if (hoursAgo < 24) {
|
||||
return `${hoursAgo} hour${hoursAgo !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
|
||||
const daysAgo = Math.floor(hoursAgo / 24);
|
||||
return `${daysAgo} day${daysAgo !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
@@ -25,6 +25,15 @@ export interface BrowserProfile {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface ProxyCheckResult {
|
||||
ip: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
timestamp: number;
|
||||
is_valid: boolean;
|
||||
}
|
||||
|
||||
export interface StoredProxy {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user