From 97b1225d40928eb2c52d552830733cf43339f787 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:29:17 +0400 Subject: [PATCH] refactor: better custom name --- src-tauri/src/api_server.rs | 14 +++ src-tauri/src/browser_runner.rs | 47 ++++++++++ src-tauri/src/downloader.rs | 45 ++++++++++ src-tauri/src/mcp_server.rs | 75 ++++++++++++++++ src-tauri/src/profile/manager.rs | 10 ++- src-tauri/tests/sync_e2e.rs | 114 ++++++++++++++++++++++++ src/app/page.tsx | 20 ++--- src/components/clone-profile-dialog.tsx | 109 ++++++++++++++++++++++ src/components/profile-data-table.tsx | 20 ++++- src/components/profile-info-dialog.tsx | 86 +++++++++--------- src/i18n/locales/en.json | 7 +- src/i18n/locales/es.json | 7 +- src/i18n/locales/fr.json | 7 +- src/i18n/locales/ja.json | 7 +- src/i18n/locales/pt.json | 7 +- src/i18n/locales/ru.json | 7 +- src/i18n/locales/zh.json | 7 +- 17 files changed, 525 insertions(+), 64 deletions(-) create mode 100644 src/components/clone-profile-dialog.tsx diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index a2ddb5b..c8cc1e2 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -39,6 +39,7 @@ pub struct ApiProfile { pub group_id: Option, pub tags: Vec, pub is_running: bool, + pub proxy_bypass_rules: Vec, } #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -79,6 +80,7 @@ pub struct UpdateProfileRequest { pub group_id: Option, pub tags: Option>, pub extension_group_id: Option, + pub proxy_bypass_rules: Option>, } #[derive(Clone)] @@ -487,6 +489,7 @@ async fn get_profiles() -> Result, StatusCode> { group_id: profile.group_id.clone(), tags: profile.tags.clone(), is_running: profile.process_id.is_some(), // Simple check based on process_id + proxy_bypass_rules: profile.proxy_bypass_rules.clone(), }) .collect(); @@ -541,6 +544,7 @@ async fn get_profile( group_id: profile.group_id.clone(), tags: profile.tags.clone(), is_running: profile.process_id.is_some(), // Simple check based on process_id + proxy_bypass_rules: profile.proxy_bypass_rules.clone(), }, })) } else { @@ -639,6 +643,7 @@ async fn create_profile( group_id: profile.group_id, tags: profile.tags, is_running: false, + proxy_bypass_rules: profile.proxy_bypass_rules, }, })) } @@ -756,6 +761,15 @@ async fn update_profile( } } + if let Some(proxy_bypass_rules) = request.proxy_bypass_rules { + if profile_manager + .update_profile_proxy_bypass_rules(&state.app_handle, &id, proxy_bypass_rules) + .is_err() + { + return Err(StatusCode::BAD_REQUEST); + } + } + // Return updated profile get_profile(Path(id), State(state)).await } diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index c6168c0..7692666 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -2726,6 +2726,53 @@ pub async fn kill_browser_profile( profile.name, profile.id ); + + // Auto-update non-running profiles and cleanup unused binaries + let browser_for_update = profile.browser.clone(); + let app_handle_for_update = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); + let mut versions = registry.get_downloaded_versions(&browser_for_update); + if !versions.is_empty() { + versions.sort_by(|a, b| crate::api_client::compare_versions(b, a)); + let latest_version = &versions[0]; + + let auto_updater = crate::auto_updater::AutoUpdater::instance(); + match auto_updater + .auto_update_profile_versions( + &app_handle_for_update, + &browser_for_update, + latest_version, + ) + .await + { + Ok(updated) => { + if !updated.is_empty() { + log::info!( + "Auto-updated {} profiles after stop: {:?}", + updated.len(), + updated + ); + } + } + Err(e) => { + log::error!("Failed to auto-update profile versions after stop: {e}"); + } + } + } + + match registry.cleanup_unused_binaries() { + Ok(cleaned) => { + if !cleaned.is_empty() { + log::info!("Cleaned up unused binaries after stop: {:?}", cleaned); + } + } + Err(e) => { + log::error!("Failed to cleanup unused binaries after stop: {e}"); + } + } + }); + Ok(()) } Err(e) => { diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index d3a5c03..957bce7 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -1033,6 +1033,51 @@ impl Downloader { tokens.remove(&download_key); } + // Auto-update non-running profiles to the new version and cleanup unused binaries + { + let browser_for_update = browser_str.clone(); + let version_for_update = version.clone(); + let app_handle_for_update = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let auto_updater = crate::auto_updater::AutoUpdater::instance(); + match auto_updater + .auto_update_profile_versions( + &app_handle_for_update, + &browser_for_update, + &version_for_update, + ) + .await + { + Ok(updated) => { + if !updated.is_empty() { + log::info!( + "Auto-updated {} profiles to {} {}: {:?}", + updated.len(), + browser_for_update, + version_for_update, + updated + ); + } + } + Err(e) => { + log::error!("Failed to auto-update profile versions: {e}"); + } + } + + let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); + match registry.cleanup_unused_binaries() { + Ok(cleaned) => { + if !cleaned.is_empty() { + log::info!("Cleaned up unused binaries after download: {:?}", cleaned); + } + } + Err(e) => { + log::error!("Failed to cleanup unused binaries: {e}"); + } + } + }); + } + Ok(version) } } diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 2086d46..d802e9d 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -725,6 +725,27 @@ impl McpServer { "required": ["profile_id"] }), }, + McpTool { + name: "update_profile_proxy_bypass_rules".to_string(), + description: + "Update proxy bypass rules for a profile. Requests matching these rules will connect directly, bypassing the proxy." + .to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the profile to update" + }, + "rules": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of bypass rules. Supports hostnames (e.g. 'example.com'), IP addresses, and regex patterns." + } + }, + "required": ["profile_id", "rules"] + }), + }, McpTool { name: "list_extensions".to_string(), description: "List all managed browser extensions. Requires Pro subscription.".to_string(), @@ -889,6 +910,11 @@ impl McpServer { // Fingerprint management "get_profile_fingerprint" => self.handle_get_profile_fingerprint(&arguments).await, "update_profile_fingerprint" => self.handle_update_profile_fingerprint(&arguments).await, + "update_profile_proxy_bypass_rules" => { + self + .handle_update_profile_proxy_bypass_rules(&arguments) + .await + } // Extension management "list_extensions" => self.handle_list_extensions().await, "list_extension_groups" => self.handle_list_extension_groups().await, @@ -2141,6 +2167,54 @@ impl McpServer { })) } + async fn handle_update_profile_proxy_bypass_rules( + &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 rules: Vec = arguments + .get("rules") + .and_then(|v| v.as_array()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing rules array".to_string(), + })? + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + + let inner = self.inner.lock().await; + let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + })?; + + let profile = ProfileManager::instance() + .update_profile_proxy_bypass_rules(app_handle, profile_id, rules.clone()) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to update proxy bypass rules: {e}"), + })?; + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!( + "Proxy bypass rules updated for profile '{}': {} rule(s) configured", + profile.name, + rules.len() + ) + }] + })) + } + async fn handle_list_extensions(&self) -> Result { if !CLOUD_AUTH.has_active_paid_subscription().await { return Err(McpError { @@ -2366,6 +2440,7 @@ mod tests { // Fingerprint tools assert!(tool_names.contains(&"get_profile_fingerprint")); assert!(tool_names.contains(&"update_profile_fingerprint")); + assert!(tool_names.contains(&"update_profile_proxy_bypass_rules")); // Extension tools assert!(tool_names.contains(&"list_extensions")); assert!(tool_names.contains(&"list_extension_groups")); diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 0e0a1ba..0864d9b 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -849,6 +849,7 @@ impl ProfileManager { pub fn clone_profile( &self, profile_id: &str, + custom_name: Option, ) -> Result> { let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?; @@ -865,7 +866,10 @@ impl ProfileManager { } let new_id = uuid::Uuid::new_v4(); - let clone_name = self.generate_clone_name(&source.name)?; + let clone_name = match custom_name { + Some(name) if !name.trim().is_empty() => name.trim().to_string(), + _ => self.generate_clone_name(&source.name)?, + }; let profiles_dir = self.get_profiles_dir(); let source_dir = profiles_dir.join(source.id.to_string()); @@ -2185,9 +2189,9 @@ pub async fn update_wayfern_config( } #[tauri::command] -pub fn clone_profile(profile_id: String) -> Result { +pub fn clone_profile(profile_id: String, name: Option) -> Result { ProfileManager::instance() - .clone_profile(&profile_id) + .clone_profile(&profile_id, name) .map_err(|e| format!("Failed to clone profile: {e}")) } diff --git a/src-tauri/tests/sync_e2e.rs b/src-tauri/tests/sync_e2e.rs index 7c3b16d..dac5aa1 100644 --- a/src-tauri/tests/sync_e2e.rs +++ b/src-tauri/tests/sync_e2e.rs @@ -238,6 +238,46 @@ fn create_test_profile_bundle(temp_dir: &Path) -> Vec { encoder.finish().unwrap() } +fn create_test_profile_bundle_with_bypass_rules(temp_dir: &Path, bypass_rules: &[&str]) -> Vec { + use flate2::write::GzEncoder; + use flate2::Compression; + use tar::Builder; + + let metadata = json!({ + "id": "test-bypass-profile-id", + "name": "Bypass Rules Profile", + "browser": "camoufox", + "version": "120.0.0", + "release_type": "stable", + "sync_enabled": true, + "tags": [], + "proxy_bypass_rules": bypass_rules + }); + + let profile_dir = temp_dir.join("bypass_profile"); + fs::create_dir_all(&profile_dir).unwrap(); + fs::write(profile_dir.join("test_file.txt"), "bypass test content").unwrap(); + + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + { + let mut tar = Builder::new(&mut encoder); + + let metadata_json = serde_json::to_string_pretty(&metadata).unwrap(); + let mut header = tar::Header::new_gnu(); + header.set_size(metadata_json.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar + .append_data(&mut header, "metadata.json", metadata_json.as_bytes()) + .unwrap(); + + tar.append_dir_all("profile", &profile_dir).unwrap(); + tar.finish().unwrap(); + } + + encoder.finish().unwrap() +} + fn extract_bundle(data: &[u8], target_dir: &Path) -> serde_json::Value { use flate2::read::GzDecoder; use tar::Archive; @@ -727,3 +767,77 @@ async fn test_delta_sync_only_changed_files() { client.delete(&file2_key, None).await.unwrap(); client.delete(&file3_key, None).await.unwrap(); } + +#[tokio::test] +async fn test_profile_bypass_rules_sync() { + ensure_sync_server_available().await; + let client = TestClient::new(); + let temp_dir = TempDir::new().unwrap(); + let profile_id = uuid::Uuid::new_v4().to_string(); + let test_key = format!("profiles/{}.tar.gz", profile_id); + + let bypass_rules = vec!["example.com", "192.168.1.0/24", ".*\\.internal\\.net"]; + + let bundle = create_test_profile_bundle_with_bypass_rules(temp_dir.path(), &bypass_rules); + + let presign = client + .presign_upload(&test_key, "application/gzip") + .await + .unwrap(); + client + .upload_bytes(&presign.url, &bundle, "application/gzip") + .await + .unwrap(); + + let stat = client.stat(&test_key).await.unwrap(); + assert!(stat.exists); + + // Download and verify bypass rules survive the round-trip + let download_presign = client.presign_download(&test_key).await.unwrap(); + let downloaded = client.download_bytes(&download_presign.url).await.unwrap(); + assert_eq!(downloaded.len(), bundle.len()); + + let extract_dir = temp_dir.path().join("extracted"); + fs::create_dir_all(&extract_dir).unwrap(); + let metadata = extract_bundle(&downloaded, &extract_dir); + + assert_eq!(metadata["name"], "Bypass Rules Profile"); + assert_eq!(metadata["browser"], "camoufox"); + + let synced_rules = metadata["proxy_bypass_rules"] + .as_array() + .expect("proxy_bypass_rules should be an array"); + assert_eq!(synced_rules.len(), 3); + assert_eq!(synced_rules[0], "example.com"); + assert_eq!(synced_rules[1], "192.168.1.0/24"); + assert_eq!(synced_rules[2], ".*\\.internal\\.net"); + + // Also verify empty bypass rules are handled correctly + let empty_bundle = create_test_profile_bundle_with_bypass_rules(temp_dir.path(), &[]); + let empty_key = format!("profiles/{}.tar.gz", uuid::Uuid::new_v4()); + + let presign2 = client + .presign_upload(&empty_key, "application/gzip") + .await + .unwrap(); + client + .upload_bytes(&presign2.url, &empty_bundle, "application/gzip") + .await + .unwrap(); + + let download_presign2 = client.presign_download(&empty_key).await.unwrap(); + let downloaded2 = client.download_bytes(&download_presign2.url).await.unwrap(); + + let extract_dir2 = temp_dir.path().join("extracted2"); + fs::create_dir_all(&extract_dir2).unwrap(); + let metadata2 = extract_bundle(&downloaded2, &extract_dir2); + + let empty_rules = metadata2["proxy_bypass_rules"] + .as_array() + .expect("proxy_bypass_rules should be an array"); + assert!(empty_rules.is_empty()); + + // Cleanup + client.delete(&test_key, None).await.unwrap(); + client.delete(&empty_key, None).await.unwrap(); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 542dc01..2fd4a2c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,7 @@ import { listen } from "@tauri-apps/api/event"; import { getCurrent } from "@tauri-apps/plugin-deep-link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog"; +import { CloneProfileDialog } from "@/components/clone-profile-dialog"; import { CommercialTrialModal } from "@/components/commercial-trial-modal"; import { CookieCopyDialog } from "@/components/cookie-copy-dialog"; import { CookieManagementDialog } from "@/components/cookie-management-dialog"; @@ -168,6 +169,7 @@ export default function Home() { const [pendingUrls, setPendingUrls] = useState([]); const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] = useState(null); + const [cloneProfile, setCloneProfile] = useState(null); const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false); const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false); const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false); @@ -585,16 +587,8 @@ export default function Home() { } }, []); - const handleCloneProfile = useCallback(async (profile: BrowserProfile) => { - try { - await invoke("clone_profile", { - profileId: profile.id, - }); - } catch (err: unknown) { - console.error("Failed to clone profile:", err); - const errorMessage = err instanceof Error ? err.message : String(err); - showErrorToast(`Failed to clone profile: ${errorMessage}`); - } + const handleCloneProfile = useCallback((profile: BrowserProfile) => { + setCloneProfile(profile); }, []); const handleDeleteProfile = useCallback(async (profile: BrowserProfile) => { @@ -1139,6 +1133,12 @@ export default function Home() { onPermissionGranted={checkNextPermission} /> + setCloneProfile(null)} + profile={cloneProfile} + /> + { diff --git a/src/components/clone-profile-dialog.tsx b/src/components/clone-profile-dialog.tsx new file mode 100644 index 0000000..1f5bd3a --- /dev/null +++ b/src/components/clone-profile-dialog.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { showErrorToast } from "@/lib/toast-utils"; +import type { BrowserProfile } from "@/types"; +import { LoadingButton } from "./loading-button"; +import { RippleButton } from "./ui/ripple"; + +interface CloneProfileDialogProps { + isOpen: boolean; + onClose: () => void; + profile: BrowserProfile | null; + onCloneComplete?: () => void; +} + +export function CloneProfileDialog({ + isOpen, + onClose, + profile, + onCloneComplete, +}: CloneProfileDialogProps) { + const { t } = useTranslation(); + const [name, setName] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (isOpen && profile) { + const defaultName = `${profile.name} (Copy)`; + setName(defaultName); + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + } else { + setIsLoading(false); + } + }, [isOpen, profile]); + + if (!profile) return null; + + const handleClone = async () => { + if (!name.trim() || isLoading) return; + setIsLoading(true); + try { + await invoke("clone_profile", { + profileId: profile.id, + name: name.trim(), + }); + onClose(); + onCloneComplete?.(); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + showErrorToast(`Failed to clone profile: ${errorMessage}`); + } finally { + setIsLoading(false); + } + }; + + return ( + !open && onClose()}> + + + {t("profileInfo.clone.title")} + + {t("profileInfo.clone.description")} + + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleClone(); + }} + placeholder={t("profileInfo.clone.namePlaceholder")} + disabled={isLoading} + /> + + + {t("common.buttons.cancel")} + + void handleClone()} + isLoading={isLoading} + disabled={!name.trim()} + > + {t("profileInfo.clone.button")} + + + + + ); +} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index b36a47d..1ce3f3e 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -2279,6 +2279,7 @@ export function ProfilesDataTable({ }, { id: "settings", + size: 40, cell: ({ row, table }) => { const meta = table.options.meta as TableMeta; const profile = row.original; @@ -2341,7 +2342,14 @@ export function ProfilesDataTable({ {headerGroup.headers.map((header) => { return ( - + {header.isPlaceholder ? null : flexRender( @@ -2374,7 +2382,15 @@ export function ProfilesDataTable({ )} > {row.getVisibleCells().map((cell) => ( - + {flexRender( cell.column.columnDef.cell, cell.getContext(), diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 794f6a7..ee57623 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -398,36 +398,63 @@ export function ProfileInfoDialog({ !open && onClose()}> - - - {profile.name} - + {t("profileInfo.title")} {t("profileInfo.tabs.info")} - - {t("profileInfo.tabs.network")} - {t("profileInfo.tabs.settings")} -
- {infoFields.map((field) => ( - - - {field.label} - - {field.value} - - ))} +
+ +

{profile.name}

+

+ {getBrowserDisplayName(profile.browser)} {profile.version} +

+
+
+
+ {infoFields.map((field) => ( + + + {field.label} + + {field.value} + + ))} +
- + +
+ {visibleActions.map((action) => ( + + ))} +
+

@@ -484,31 +511,6 @@ export function ProfileInfoDialog({

- -
- {visibleActions.map((action) => ( - - ))} -
-