From 2a38ab2674aea04dd7d12590f089afdf0c41b033 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:58:38 +0400 Subject: [PATCH] feat: profile cloning --- .github/workflows/lint-rs.yml | 2 +- src-tauri/src/daemon_spawn.rs | 8 +-- src-tauri/src/lib.rs | 7 ++- src-tauri/src/profile/manager.rs | 86 ++++++++++++++++++++++++++- src-tauri/src/profile_importer.rs | 2 +- src/app/page.tsx | 13 ++++ src/components/profile-data-table.tsx | 13 ++++ src/i18n/locales/en.json | 3 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/fr.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/pt.json | 3 +- src/i18n/locales/ru.json | 3 +- src/i18n/locales/zh.json | 3 +- 14 files changed, 134 insertions(+), 18 deletions(-) diff --git a/.github/workflows/lint-rs.yml b/.github/workflows/lint-rs.yml index 2c8a5c2..7358a9f 100644 --- a/.github/workflows/lint-rs.yml +++ b/.github/workflows/lint-rs.yml @@ -30,7 +30,7 @@ jobs: build: strategy: matrix: - os: [macos-latest, ubuntu-22.04] + os: [macos-latest, ubuntu-22.04, windows-latest] runs-on: ${{ matrix.os }} diff --git a/src-tauri/src/daemon_spawn.rs b/src-tauri/src/daemon_spawn.rs index 2d8101c..0a1e8fd 100644 --- a/src-tauri/src/daemon_spawn.rs +++ b/src-tauri/src/daemon_spawn.rs @@ -60,6 +60,7 @@ fn is_daemon_running() -> bool { } } +#[cfg(target_os = "macos")] fn is_dev_mode() -> bool { if let Ok(current_exe) = std::env::current_exe() { let path_str = current_exe.to_string_lossy(); @@ -184,11 +185,8 @@ pub fn spawn_daemon() -> Result<(), String> { // Check if we got a state file at least let state = read_state(); - if state.daemon_pid.is_some() { - log::info!( - "Daemon appears to have started (PID {} in state file)", - state.daemon_pid.unwrap() - ); + if let Some(pid) = state.daemon_pid { + log::info!("Daemon appears to have started (PID {} in state file)", pid); return Ok(()); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1d891df..b3f8744 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -54,9 +54,9 @@ use browser_runner::{ }; use profile::manager::{ - check_browser_status, create_browser_profile_new, delete_profile, list_browser_profiles, - rename_profile, update_camoufox_config, update_profile_note, update_profile_proxy, - update_profile_tags, update_wayfern_config, + 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_tags, update_wayfern_config, }; use browser_version_manager::{ @@ -1092,6 +1092,7 @@ pub fn run() { download_browser, cancel_download, delete_profile, + clone_profile, check_browser_exists, create_browser_profile_new, list_browser_profiles, diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index db1e450..dedc415 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -780,6 +780,84 @@ impl ProfileManager { Ok(()) } + fn generate_clone_name(&self, original_name: &str) -> Result> { + let profiles = self.list_profiles()?; + let existing_names: std::collections::HashSet = + profiles.iter().map(|p| p.name.clone()).collect(); + + let candidate = format!("{original_name} (Copy)"); + if !existing_names.contains(&candidate) { + return Ok(candidate); + } + + for i in 2.. { + let candidate = format!("{original_name} (Copy {i})"); + if !existing_names.contains(&candidate) { + return Ok(candidate); + } + } + + unreachable!() + } + + pub fn clone_profile( + &self, + profile_id: &str, + ) -> Result> { + let profile_uuid = + uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?; + let profiles = self.list_profiles()?; + let source = profiles + .into_iter() + .find(|p| p.id == profile_uuid) + .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; + + if source.process_id.is_some() { + return Err( + "Cannot clone profile while browser is running. Please stop the browser first.".into(), + ); + } + + let new_id = uuid::Uuid::new_v4(); + let clone_name = self.generate_clone_name(&source.name)?; + + let profiles_dir = self.get_profiles_dir(); + let source_dir = profiles_dir.join(source.id.to_string()); + let dest_dir = profiles_dir.join(new_id.to_string()); + + if source_dir.exists() { + crate::profile_importer::ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir)?; + } else { + fs::create_dir_all(&dest_dir)?; + } + + let new_profile = BrowserProfile { + id: new_id, + name: clone_name, + browser: source.browser, + version: source.version, + proxy_id: source.proxy_id, + process_id: None, + last_launch: None, + release_type: source.release_type, + camoufox_config: source.camoufox_config, + wayfern_config: source.wayfern_config, + group_id: source.group_id, + tags: source.tags, + note: source.note, + sync_enabled: false, + last_sync: None, + }; + + self.save_profile(&new_profile)?; + + if let Err(e) = events::emit_empty("profiles-changed") { + log::warn!("Warning: Failed to emit profiles-changed event: {e}"); + } + + Ok(new_profile) + } + pub async fn update_camoufox_config( &self, app_handle: tauri::AppHandle, @@ -1862,7 +1940,13 @@ pub async fn update_wayfern_config( .map_err(|e| format!("Failed to update Wayfern config: {e}")) } -// Global singleton instance +#[tauri::command] +pub fn clone_profile(profile_id: String) -> Result { + ProfileManager::instance() + .clone_profile(&profile_id) + .map_err(|e| format!("Failed to clone profile: {e}")) +} + #[tauri::command] pub fn delete_profile(app_handle: tauri::AppHandle, profile_id: String) -> Result<(), String> { ProfileManager::instance() diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 11b71ac..e0ae683 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -592,7 +592,7 @@ impl ProfileImporter { } /// Recursively copy directory contents - fn copy_directory_recursive( + pub fn copy_directory_recursive( source: &Path, destination: &Path, ) -> Result<(), Box> { diff --git a/src/app/page.tsx b/src/app/page.tsx index bd6dd2c..dc0c802 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -488,6 +488,18 @@ 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 handleDeleteProfile = useCallback(async (profile: BrowserProfile) => { console.log("Attempting to delete profile:", profile.name); @@ -876,6 +888,7 @@ export default function Home() { profiles={filteredProfiles} onLaunchProfile={launchProfile} onKillProfile={handleKillProfile} + onCloneProfile={handleCloneProfile} onDeleteProfile={handleDeleteProfile} onRenameProfile={handleRenameProfile} onConfigureCamoufox={handleConfigureCamoufox} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index ae29a15..81f774b 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -159,6 +159,7 @@ type TableMeta = { // Overflow actions onAssignProfilesToGroup?: (profileIds: string[]) => void; onConfigureCamoufox?: (profile: BrowserProfile) => void; + onCloneProfile?: (profile: BrowserProfile) => void; onCopyCookiesToProfile?: (profile: BrowserProfile) => void; // Traffic snapshots (lightweight real-time data) @@ -672,6 +673,7 @@ interface ProfilesDataTableProps { profiles: BrowserProfile[]; onLaunchProfile: (profile: BrowserProfile) => void | Promise; onKillProfile: (profile: BrowserProfile) => void | Promise; + onCloneProfile: (profile: BrowserProfile) => void | Promise; onDeleteProfile: (profile: BrowserProfile) => void | Promise; onRenameProfile: (profileId: string, newName: string) => Promise; onConfigureCamoufox: (profile: BrowserProfile) => void; @@ -695,6 +697,7 @@ export function ProfilesDataTable({ profiles, onLaunchProfile, onKillProfile, + onCloneProfile, onDeleteProfile, onRenameProfile, onConfigureCamoufox, @@ -1310,6 +1313,7 @@ export function ProfilesDataTable({ // Overflow actions onAssignProfilesToGroup, + onCloneProfile, onConfigureCamoufox, onCopyCookiesToProfile, @@ -1359,6 +1363,7 @@ export function ProfilesDataTable({ onKillProfile, onLaunchProfile, onAssignProfilesToGroup, + onCloneProfile, onConfigureCamoufox, onCopyCookiesToProfile, syncStatuses, @@ -1999,6 +2004,14 @@ export function ProfilesDataTable({ Copy Cookies to Profile )} + { + meta.onCloneProfile?.(profile); + }} + disabled={isDisabled} + > + Clone Profile + { setProfileToDelete(profile); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index eb82d84..f3c2735 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -156,7 +156,8 @@ "edit": "Edit", "delete": "Delete", "copyCookies": "Copy Cookies", - "configure": "Configure" + "configure": "Configure", + "clone": "Clone Profile" } }, "createProfile": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e12439d..436a61a 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -156,7 +156,8 @@ "edit": "Editar", "delete": "Eliminar", "copyCookies": "Copiar Cookies", - "configure": "Configurar" + "configure": "Configurar", + "clone": "Clonar perfil" } }, "createProfile": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 867ccf4..8bc6332 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -156,7 +156,8 @@ "edit": "Modifier", "delete": "Supprimer", "copyCookies": "Copier les cookies", - "configure": "Configurer" + "configure": "Configurer", + "clone": "Cloner le profil" } }, "createProfile": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 08f9649..36f2efc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -156,7 +156,8 @@ "edit": "編集", "delete": "削除", "copyCookies": "Cookieをコピー", - "configure": "設定" + "configure": "設定", + "clone": "プロファイルを複製" } }, "createProfile": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index dac0a87..94b45e1 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -156,7 +156,8 @@ "edit": "Editar", "delete": "Excluir", "copyCookies": "Copiar Cookies", - "configure": "Configurar" + "configure": "Configurar", + "clone": "Clonar perfil" } }, "createProfile": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6e22686..64a04c9 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -156,7 +156,8 @@ "edit": "Редактировать", "delete": "Удалить", "copyCookies": "Копировать Cookie", - "configure": "Настроить" + "configure": "Настроить", + "clone": "Клонировать профиль" } }, "createProfile": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8753431..c287789 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -156,7 +156,8 @@ "edit": "编辑", "delete": "删除", "copyCookies": "复制 Cookies", - "configure": "配置" + "configure": "配置", + "clone": "克隆配置文件" } }, "createProfile": {