diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index e5dc454..29d099f 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -49,7 +49,7 @@ pub struct ApiProfileResponse { pub struct CreateProfileRequest { pub name: String, pub browser: String, - pub version: Option, + pub version: String, pub proxy_id: Option, pub release_type: Option, pub camoufox_config: Option, @@ -350,8 +350,8 @@ async fn create_profile( &state.app_handle, &request.name, &request.browser, - request.version.as_deref().unwrap_or("stable"), - request.release_type.as_deref().unwrap_or("release"), + &request.version, + request.release_type.as_deref().unwrap_or("stable"), request.proxy_id.clone(), camoufox_config, request.group_id.clone(), @@ -362,7 +362,7 @@ async fn create_profile( // Apply tags if provided if let Some(tags) = &request.tags { if profile_manager - .update_profile_tags(&profile.name, tags.clone()) + .update_profile_tags(&state.app_handle, &profile.name, tags.clone()) .is_err() { return Err(StatusCode::INTERNAL_SERVER_ERROR); @@ -410,14 +410,14 @@ async fn update_profile( // Update profile fields if let Some(new_name) = request.name { - if profile_manager.rename_profile(&name, &new_name).is_err() { + if profile_manager.rename_profile(&state.app_handle, &name, &new_name).is_err() { return Err(StatusCode::BAD_REQUEST); } } if let Some(version) = request.version { if profile_manager - .update_profile_version(&name, &version) + .update_profile_version(&state.app_handle, &name, &version) .is_err() { return Err(StatusCode::BAD_REQUEST); @@ -453,7 +453,7 @@ async fn update_profile( if let Some(group_id) = request.group_id { if profile_manager - .assign_profiles_to_group(vec![name.clone()], Some(group_id)) + .assign_profiles_to_group(&state.app_handle, vec![name.clone()], Some(group_id)) .is_err() { return Err(StatusCode::BAD_REQUEST); @@ -461,7 +461,7 @@ async fn update_profile( } if let Some(tags) = request.tags { - if profile_manager.update_profile_tags(&name, tags).is_err() { + if profile_manager.update_profile_tags(&state.app_handle, &name, tags).is_err() { return Err(StatusCode::BAD_REQUEST); } @@ -479,10 +479,10 @@ async fn update_profile( async fn delete_profile( Path(id): Path, - State(_state): State, + State(state): State, ) -> Result { let profile_manager = ProfileManager::instance(); - match profile_manager.delete_profile(&id) { + match profile_manager.delete_profile(&state.app_handle, &id) { Ok(_) => Ok(StatusCode::NO_CONTENT), Err(_) => Err(StatusCode::BAD_REQUEST), } diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 4f1a50a..60a35ac 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -148,11 +148,11 @@ impl AutoUpdater { ); // Clone app_handle for the async task - let app_handle_clone = app_handle.clone(); let browser = notification.browser.clone(); let new_version = notification.new_version.clone(); let notification_id = notification.id.clone(); let affected_profiles = notification.affected_profiles.clone(); + let app_handle_clone = app_handle.clone(); // Spawn async task to handle the download and auto-update tokio::spawn(async move { @@ -166,6 +166,7 @@ impl AutoUpdater { // Browser already exists, go straight to profile update match crate::auto_updater::complete_browser_update_with_auto_update( + app_handle_clone, browser.clone(), new_version.clone(), ) @@ -293,6 +294,7 @@ impl AutoUpdater { /// Automatically update all affected profile versions after browser download pub async fn auto_update_profile_versions( &self, + app_handle: &tauri::AppHandle, browser: &str, new_version: &str, ) -> Result, Box> { @@ -314,7 +316,7 @@ impl AutoUpdater { // Check if this is an update (newer version) if self.is_version_newer(new_version, &profile.version) { // Update the profile version - match profile_manager.update_profile_version(&profile.name, new_version) { + match profile_manager.update_profile_version(app_handle, &profile.name, new_version) { Ok(_) => { updated_profiles.push(profile.name); } @@ -332,12 +334,13 @@ impl AutoUpdater { /// Complete browser update process with auto-update of profile versions pub async fn complete_browser_update_with_auto_update( &self, + app_handle: &tauri::AppHandle, browser: &str, new_version: &str, ) -> Result, Box> { // Auto-update profile versions first let updated_profiles = self - .auto_update_profile_versions(browser, new_version) + .auto_update_profile_versions(app_handle, browser, new_version) .await?; // Remove browser from disabled list and clean up auto-update tracking @@ -480,12 +483,13 @@ pub async fn dismiss_update_notification(notification_id: String) -> Result<(), #[tauri::command] pub async fn complete_browser_update_with_auto_update( + app_handle: tauri::AppHandle, browser: String, new_version: String, ) -> Result, String> { let updater = AutoUpdater::instance(); updater - .complete_browser_update_with_auto_update(&browser, &new_version) + .complete_browser_update_with_auto_update(&app_handle, &browser, &new_version) .await .map_err(|e| format!("Failed to complete browser update: {e}")) } @@ -876,7 +880,7 @@ mod tests { use tempfile::TempDir; // Create a temporary directory for testing - let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let temp_dir = TempDir::new().unwrap(); // Create a mock settings manager that uses the temp directory struct TestSettingsManager { diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 8eaaf40..3545ffa 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -909,9 +909,9 @@ impl BrowserRunner { }) } - pub fn delete_profile(&self, profile_id: &str) -> Result<(), Box> { + pub fn delete_profile(&self, app_handle: tauri::AppHandle, profile_id: &str) -> Result<(), Box> { let profile_manager = ProfileManager::instance(); - profile_manager.delete_profile(profile_id)?; + profile_manager.delete_profile(&app_handle, profile_id)?; // Always perform cleanup after profile deletion to remove unused binaries if let Err(e) = self.cleanup_unused_binaries_internal() { @@ -1055,7 +1055,7 @@ impl BrowserRunner { let system = System::new_all(); if let Some(process) = system.process(sysinfo::Pid::from(pid as usize)) { let cmd = process.cmd(); - let exe_name = process.name().to_string_lossy().to_lowercase(); + let exe_name = process.name().to_string_lossy(); // Verify this process is actually our browser let is_correct_browser = match profile.browser.as_str() { @@ -1974,12 +1974,13 @@ pub async fn update_profile_proxy( #[tauri::command] pub fn update_profile_tags( + app_handle: tauri::AppHandle, profile_name: String, tags: Vec, ) -> Result { let profile_manager = ProfileManager::instance(); profile_manager - .update_profile_tags(&profile_name, tags) + .update_profile_tags(&app_handle, &profile_name, tags) .map_err(|e| format!("Failed to update profile tags: {e}")) } @@ -1997,21 +1998,21 @@ pub async fn check_browser_status( #[tauri::command] pub fn rename_profile( - _app_handle: tauri::AppHandle, + app_handle: tauri::AppHandle, old_name: &str, new_name: &str, ) -> Result { let profile_manager = ProfileManager::instance(); profile_manager - .rename_profile(old_name, new_name) + .rename_profile(&app_handle, old_name, new_name) .map_err(|e| format!("Failed to rename profile: {e}")) } #[tauri::command] -pub fn delete_profile(_app_handle: tauri::AppHandle, profile_id: String) -> Result<(), String> { +pub async fn delete_profile(app_handle: tauri::AppHandle, profile_id: String) -> Result<(), String> { let browser_runner = BrowserRunner::instance(); browser_runner - .delete_profile(profile_id.as_str()) + .delete_profile(app_handle, &profile_id) .map_err(|e| format!("Failed to delete profile: {e}")) } diff --git a/src-tauri/src/group_manager.rs b/src-tauri/src/group_manager.rs index 9c8ab1d..e912062 100644 --- a/src-tauri/src/group_manager.rs +++ b/src-tauri/src/group_manager.rs @@ -519,19 +519,23 @@ pub async fn delete_profile_group(group_id: String) -> Result<(), String> { #[tauri::command] pub async fn assign_profiles_to_group( + app_handle: tauri::AppHandle, profile_names: Vec, group_id: Option, ) -> Result<(), String> { let profile_manager = crate::profile::ProfileManager::instance(); profile_manager - .assign_profiles_to_group(profile_names, group_id) + .assign_profiles_to_group(&app_handle, profile_names, group_id) .map_err(|e| format!("Failed to assign profiles to group: {e}")) } #[tauri::command] -pub async fn delete_selected_profiles(profile_names: Vec) -> Result<(), String> { +pub async fn delete_selected_profiles( + app_handle: tauri::AppHandle, + profile_names: Vec, +) -> Result<(), String> { let profile_manager = crate::profile::ProfileManager::instance(); profile_manager - .delete_multiple_profiles(profile_names) + .delete_multiple_profiles(&app_handle, profile_names) .map_err(|e| format!("Failed to delete profiles: {e}")) } diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 8ac3d7d..b73bf83 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -218,6 +218,11 @@ impl ProfileManager { self.disable_proxy_settings_in_profile(&profile_data_dir)?; } + // Emit profile creation event + if let Err(e) = app_handle.emit("profiles-changed", ()) { + println!("Warning: Failed to emit profiles-changed event: {e}"); + } + Ok(profile) } @@ -262,6 +267,7 @@ impl ProfileManager { pub fn rename_profile( &self, + app_handle: &tauri::AppHandle, old_name: &str, new_name: &str, ) -> Result> { @@ -291,10 +297,19 @@ impl ProfileManager { let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); }); + // Emit profile rename event + if let Err(e) = app_handle.emit("profiles-changed", ()) { + println!("Warning: Failed to emit profiles-changed event: {e}"); + } + Ok(profile) } - pub fn delete_profile(&self, profile_name: &str) -> Result<(), Box> { + pub fn delete_profile( + &self, + app_handle: &tauri::AppHandle, + profile_name: &str, + ) -> Result<(), Box> { println!("Attempting to delete profile: {profile_name}"); // Find the profile by name @@ -333,11 +348,17 @@ impl ProfileManager { let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); }); + // Emit profile deletion event + if let Err(e) = app_handle.emit("profiles-changed", ()) { + println!("Warning: Failed to emit profiles-changed event: {e}"); + } + Ok(()) } pub fn update_profile_version( &self, + app_handle: &tauri::AppHandle, profile_name: &str, version: &str, ) -> Result> { @@ -379,11 +400,17 @@ impl ProfileManager { // Save the updated profile self.save_profile(&profile)?; + // Emit profile update event + if let Err(e) = app_handle.emit("profiles-changed", ()) { + println!("Warning: Failed to emit profiles-changed event: {e}"); + } + Ok(profile) } pub fn assign_profiles_to_group( &self, + app_handle: &tauri::AppHandle, profile_names: Vec, group_id: Option, ) -> Result<(), Box> { @@ -412,11 +439,17 @@ impl ProfileManager { let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); }); + // Emit profile group assignment event + if let Err(e) = app_handle.emit("profiles-changed", ()) { + println!("Warning: Failed to emit profiles-changed event: {e}"); + } + Ok(()) } pub fn update_profile_tags( &self, + app_handle: &tauri::AppHandle, profile_name: &str, tags: Vec, ) -> Result> { @@ -444,11 +477,17 @@ impl ProfileManager { let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); }); + // Emit profile tags update event + if let Err(e) = app_handle.emit("profiles-changed", ()) { + println!("Warning: Failed to emit profiles-changed event: {e}"); + } + Ok(profile) } pub fn delete_multiple_profiles( &self, + app_handle: &tauri::AppHandle, profile_names: Vec, ) -> Result<(), Box> { let profiles = self.list_profiles()?; @@ -478,6 +517,11 @@ impl ProfileManager { } } + // Emit profile deletion event + if let Err(e) = app_handle.emit("profiles-changed", ()) { + println!("Warning: Failed to emit profiles-changed event: {e}"); + } + Ok(()) } @@ -502,7 +546,7 @@ impl ProfileManager { })?; // Check if the browser is currently running using the comprehensive status check - let is_running = self.check_browser_status(app_handle, &profile).await?; + let is_running = self.check_browser_status(app_handle.clone(), &profile).await?; if is_running { return Err( @@ -522,6 +566,11 @@ impl ProfileManager { println!("Camoufox configuration updated for profile '{profile_name}'."); + // Emit profile config update event + if let Err(e) = app_handle.emit("profiles-changed", ()) { + println!("Warning: Failed to emit profiles-changed event: {e}"); + } + Ok(()) } @@ -592,6 +641,11 @@ impl ProfileManager { println!("Warning: Failed to emit profile update event: {e}"); } + // Emit general profiles changed event for profile list updates + if let Err(e) = app_handle.emit("profiles-changed", ()) { + println!("Warning: Failed to emit profiles-changed event: {e}"); + } + Ok(profile) } diff --git a/src/app/page.tsx b/src/app/page.tsx index ceac63a..fca9ac3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -20,10 +20,11 @@ import { SettingsDialog } from "@/components/settings-dialog"; import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"; import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; +import { useProfileEvents } from "@/hooks/use-profile-events"; import { useUpdateNotifications } from "@/hooks/use-update-notifications"; import { useVersionUpdater } from "@/hooks/use-version-updater"; import { showErrorToast, showToast } from "@/lib/toast-utils"; -import type { BrowserProfile, CamoufoxConfig, GroupWithCount } from "@/types"; +import type { BrowserProfile, CamoufoxConfig } from "@/types"; type BrowserTypeString = | "mullvad-browser" @@ -44,8 +45,18 @@ export default function Home() { // Mount global version update listener/toasts useVersionUpdater(); const [isInitializing, setIsInitializing] = useState(true); - const [profiles, setProfiles] = useState([]); - const [error, setError] = useState(null); + + // Use the new profile events hook for centralized profile management + const { + profiles, + groups, + runningProfiles, + isLoading: profilesLoading, + error: profilesError, + loadProfiles, + clearError: clearProfilesError, + } = useProfileEvents(); + const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false); @@ -67,8 +78,6 @@ export default function Home() { useState(null); const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false); const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); - const [groups, setGroups] = useState([]); - const [areGroupsLoading, setGroupsLoading] = useState(true); const [currentPermissionType, setCurrentPermissionType] = useState("microphone"); const [showBulkDeleteConfirmation, setShowBulkDeleteConfirmation] = @@ -76,9 +85,7 @@ export default function Home() { const [isBulkDeleting, setIsBulkDeleting] = useState(false); const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } = usePermissions(); - const [runningProfiles, setRunningProfiles] = useState>( - new Set(), - ); + const handleSelectGroup = useCallback((groupId: string) => { setSelectedGroupId(groupId); setSelectedProfiles([]); @@ -147,11 +154,6 @@ export default function Home() { "Failed to download missing components:", downloadError, ); - setError( - `Failed to download missing components: ${JSON.stringify( - downloadError, - )}`, - ); } } } catch (err: unknown) { @@ -159,65 +161,6 @@ export default function Home() { } }, []); - // Function to check and sync profile running states with actual process status - const syncProfileRunningStates = useCallback( - async (profiles: BrowserProfile[]) => { - try { - const statusChecks = profiles.map(async (profile) => { - try { - const isRunning = await invoke("check_browser_status", { - profile, - }); - return { id: profile.id, isRunning }; - } catch (error) { - console.error( - `Failed to check status for profile ${profile.name}:`, - error, - ); - return { id: profile.id, isRunning: false }; - } - }); - - const statuses = await Promise.all(statusChecks); - - // Update running profiles state based on actual status - setRunningProfiles((prev) => { - const next = new Set(prev); - statuses.forEach(({ id, isRunning }) => { - if (isRunning) { - next.add(id); - } else { - next.delete(id); - } - }); - return next; - }); - } catch (error) { - console.error("Failed to sync profile running states:", error); - } - }, - [], - ); - - // Simple profiles loader without updates check (for use as callback) - const loadProfiles = useCallback(async () => { - try { - const profileList = await invoke( - "list_browser_profiles", - ); - setProfiles(profileList); - - // Check and sync profile running status after loading profiles - await syncProfileRunningStates(profileList); - - // Check for missing binaries after loading profiles - await checkMissingBinaries(); - } catch (err: unknown) { - console.error("Failed to load profiles:", err); - setError(`Failed to load profiles: ${JSON.stringify(err)}`); - } - }, [checkMissingBinaries, syncProfileRunningStates]); - const [processingUrls, setProcessingUrls] = useState>(new Set()); const handleUrlOpen = useCallback( @@ -254,26 +197,6 @@ export default function Home() { const updateNotifications = useUpdateNotifications(loadProfiles); const { checkForUpdates, isUpdating } = updateNotifications; - // Profiles loader with update check (for initial load and manual refresh) - const loadProfilesWithUpdateCheck = useCallback(async () => { - try { - const profileList = await invoke( - "list_browser_profiles", - ); - setProfiles(profileList); - - // Check and sync profile running status after loading profiles - await syncProfileRunningStates(profileList); - - // Check for updates after loading profiles - await checkForUpdates(); - await checkMissingBinaries(); - } catch (err: unknown) { - console.error("Failed to load profiles:", err); - setError(`Failed to load profiles: ${JSON.stringify(err)}`); - } - }, [checkForUpdates, checkMissingBinaries, syncProfileRunningStates]); - useAppUpdateNotifications(); // Check for startup URLs but only process them once @@ -320,9 +243,8 @@ export default function Home() { await invoke("warm_up_nodecar"); } catch (err) { if (!cancelled) { - setError( - `Initialization failed: ${err instanceof Error ? err.message : String(err)}`, - ); + // Don't set error here since useProfileEvents handles profile errors + console.error("Initialization failed:", err); } } finally { if (!cancelled) setIsInitializing(false); @@ -334,6 +256,14 @@ export default function Home() { }; }, []); + // Handle profile errors from useProfileEvents hook + useEffect(() => { + if (profilesError) { + showErrorToast(profilesError); + clearProfilesError(); + } + }, [profilesError, clearProfilesError]); + const checkAllPermissions = useCallback(async () => { try { // Wait for permissions to be initialized before checking @@ -390,7 +320,7 @@ export default function Home() { "Received show create profile dialog request:", event.payload, ); - setError( + showErrorToast( "No profiles available. Please create a profile first before opening URLs.", ); setCreateProfileDialogOpen(true); @@ -426,38 +356,24 @@ export default function Home() { const handleSaveCamoufoxConfig = useCallback( async (profile: BrowserProfile, config: CamoufoxConfig) => { - setError(null); try { await invoke("update_camoufox_config", { profileName: profile.name, config, }); - await loadProfiles(); + // No need to manually reload - useProfileEvents will handle the update setCamoufoxConfigDialogOpen(false); } catch (err: unknown) { console.error("Failed to update camoufox config:", err); - setError(`Failed to update camoufox config: ${JSON.stringify(err)}`); + showErrorToast( + `Failed to update camoufox config: ${JSON.stringify(err)}`, + ); throw err; } }, - [loadProfiles], + [], ); - const loadGroups = useCallback(async () => { - setGroupsLoading(true); - try { - const groupsWithCounts = await invoke( - "get_groups_with_profile_counts", - ); - setGroups(groupsWithCounts); - } catch (err) { - console.error("Failed to load groups with counts:", err); - setGroups([]); - } finally { - setGroupsLoading(false); - } - }, []); - const handleCreateProfile = useCallback( async (profileData: { name: string; @@ -468,8 +384,6 @@ export default function Home() { camoufoxConfig?: CamoufoxConfig; groupId?: string; }) => { - setError(null); - try { await invoke("create_browser_profile_new", { name: profileData.name, @@ -483,11 +397,9 @@ export default function Home() { (selectedGroupId !== "default" ? selectedGroupId : undefined), }); - await loadProfiles(); - await loadGroups(); - // Trigger proxy data reload in the table + // No need to manually reload - useProfileEvents will handle the update } catch (error) { - setError( + showErrorToast( `Failed to create profile: ${ error instanceof Error ? error.message : String(error) }`, @@ -495,36 +407,10 @@ export default function Home() { throw error; } }, - [loadProfiles, loadGroups, selectedGroupId], + [selectedGroupId], ); - useEffect(() => { - let unlisten: (() => void) | undefined; - (async () => { - try { - unlisten = await listen<{ id: string; is_running: boolean }>( - "profile-running-changed", - (event) => { - const { id, is_running } = event.payload; - setRunningProfiles((prev) => { - const next = new Set(prev); - if (is_running) next.add(id); - else next.delete(id); - return next; - }); - }, - ); - } catch { - // best-effort listener - } - })(); - return () => { - if (unlisten) unlisten(); - }; - }, []); - const launchProfile = useCallback(async (profile: BrowserProfile) => { - setError(null); console.log("Starting launch for profile:", profile.name); try { @@ -535,100 +421,84 @@ export default function Home() { } catch (err: unknown) { console.error("Failed to launch browser:", err); const errorMessage = err instanceof Error ? err.message : String(err); - setError(`Failed to launch browser: ${errorMessage}`); + showErrorToast(`Failed to launch browser: ${errorMessage}`); // Re-throw the error so the table component can handle loading state cleanup throw err; } }, []); - const handleDeleteProfile = useCallback( - async (profile: BrowserProfile) => { - setError(null); - console.log("Attempting to delete profile:", profile.name); + const handleDeleteProfile = useCallback(async (profile: BrowserProfile) => { + console.log("Attempting to delete profile:", profile.name); - try { - // First check if the browser is running for this profile - const isRunning = await invoke("check_browser_status", { - profile, - }); + try { + // First check if the browser is running for this profile + const isRunning = await invoke("check_browser_status", { + profile, + }); - if (isRunning) { - setError( - "Cannot delete profile while browser is running. Please stop the browser first.", - ); - return; - } - - // Attempt to delete the profile - await invoke("delete_profile", { profileName: profile.name }); - console.log("Profile deletion command completed successfully"); - - // Give a small delay to ensure file system operations complete - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Reload profiles and groups to ensure UI is updated - await loadProfiles(); - await loadGroups(); - - console.log("Profile deleted and profiles reloaded successfully"); - } catch (err: unknown) { - console.error("Failed to delete profile:", err); - const errorMessage = err instanceof Error ? err.message : String(err); - setError(`Failed to delete profile: ${errorMessage}`); + if (isRunning) { + showErrorToast( + "Cannot delete profile while browser is running. Please stop the browser first.", + ); + return; } - }, - [loadProfiles, loadGroups], - ); + + // Attempt to delete the profile + await invoke("delete_profile", { profileName: profile.name }); + console.log("Profile deletion command completed successfully"); + + // No need to manually reload - useProfileEvents will handle the update + console.log("Profile deleted successfully"); + } catch (err: unknown) { + console.error("Failed to delete profile:", err); + const errorMessage = err instanceof Error ? err.message : String(err); + showErrorToast(`Failed to delete profile: ${errorMessage}`); + } + }, []); const handleRenameProfile = useCallback( async (oldName: string, newName: string) => { - setError(null); try { await invoke("rename_profile", { oldName, newName }); - await loadProfiles(); + // No need to manually reload - useProfileEvents will handle the update } catch (err: unknown) { console.error("Failed to rename profile:", err); - setError(`Failed to rename profile: ${JSON.stringify(err)}`); + showErrorToast(`Failed to rename profile: ${JSON.stringify(err)}`); throw err; } }, - [loadProfiles], + [], ); - const handleKillProfile = useCallback( - async (profile: BrowserProfile) => { - setError(null); - console.log("Starting kill for profile:", profile.name); + const handleKillProfile = useCallback(async (profile: BrowserProfile) => { + console.log("Starting kill for profile:", profile.name); - try { - await invoke("kill_browser_profile", { profile }); - await loadProfiles(); - console.log("Successfully killed profile:", profile.name); - // Don't reload profiles here - let the backend events handle UI updates - } catch (err: unknown) { - console.error("Failed to kill browser:", err); - const errorMessage = err instanceof Error ? err.message : String(err); - setError(`Failed to kill browser: ${errorMessage}`); - // Re-throw the error so the table component can handle loading state cleanup - throw err; - } - }, - [loadProfiles], - ); + try { + await invoke("kill_browser_profile", { profile }); + console.log("Successfully killed profile:", profile.name); + // No need to manually reload - useProfileEvents will handle the update + } catch (err: unknown) { + console.error("Failed to kill browser:", err); + const errorMessage = err instanceof Error ? err.message : String(err); + showErrorToast(`Failed to kill browser: ${errorMessage}`); + // Re-throw the error so the table component can handle loading state cleanup + throw err; + } + }, []); const handleDeleteSelectedProfiles = useCallback( async (profileNames: string[]) => { - setError(null); try { await invoke("delete_selected_profiles", { profileNames }); - await loadProfiles(); - await loadGroups(); + // No need to manually reload - useProfileEvents will handle the update } catch (err: unknown) { console.error("Failed to delete selected profiles:", err); - setError(`Failed to delete selected profiles: ${JSON.stringify(err)}`); + showErrorToast( + `Failed to delete selected profiles: ${JSON.stringify(err)}`, + ); } }, - [loadProfiles, loadGroups], + [], ); const handleAssignProfilesToGroup = useCallback((profileNames: string[]) => { @@ -649,17 +519,18 @@ export default function Home() { await invoke("delete_selected_profiles", { profileNames: selectedProfiles, }); - await loadProfiles(); - await loadGroups(); + // No need to manually reload - useProfileEvents will handle the update setSelectedProfiles([]); setShowBulkDeleteConfirmation(false); } catch (error) { console.error("Failed to delete selected profiles:", error); - setError(`Failed to delete selected profiles: ${JSON.stringify(error)}`); + showErrorToast( + `Failed to delete selected profiles: ${JSON.stringify(error)}`, + ); } finally { setIsBulkDeleting(false); } - }, [selectedProfiles, loadProfiles, loadGroups]); + }, [selectedProfiles]); const handleBulkGroupAssignment = useCallback(() => { if (selectedProfiles.length === 0) return; @@ -668,20 +539,16 @@ export default function Home() { }, [selectedProfiles, handleAssignProfilesToGroup]); const handleGroupAssignmentComplete = useCallback(async () => { - await loadProfiles(); - await loadGroups(); + // No need to manually reload - useProfileEvents will handle the update setGroupAssignmentDialogOpen(false); setSelectedProfilesForGroup([]); - }, [loadProfiles, loadGroups]); + }, []); const handleGroupManagementComplete = useCallback(async () => { - await loadGroups(); - }, [loadGroups]); + // No need to manually reload - useProfileEvents will handle the update + }, []); useEffect(() => { - void loadProfilesWithUpdateCheck(); - void loadGroups(); - // Check for startup default browser prompt void checkStartupPrompt(); @@ -707,6 +574,11 @@ export default function Home() { 30 * 60 * 1000, ); + // Check for missing binaries after initial profile load + if (!profilesLoading && profiles.length > 0) { + void checkMissingBinaries(); + } + return () => { clearInterval(updateInterval); if (cleanup) { @@ -714,12 +586,13 @@ export default function Home() { } }; }, [ - loadProfilesWithUpdateCheck, checkForUpdates, checkStartupPrompt, listenForUrlEvents, checkCurrentUrl, - loadGroups, + checkMissingBinaries, + profilesLoading, + profiles.length, ]); // Show deprecation warning for unsupported profiles (with names) @@ -755,13 +628,6 @@ export default function Home() { } }, [profiles]); - useEffect(() => { - if (error) { - showErrorToast(error); - setError(null); - } - }, [error]); - // Check permissions when they are initialized useEffect(() => { if (isInitialized) { @@ -798,7 +664,7 @@ export default function Home() { selectedGroupId={selectedGroupId} onGroupSelect={handleSelectGroup} groups={groups} - isLoading={areGroupsLoading} + isLoading={profilesLoading} /> ([]); + // Use the centralized profile events hook + const { profiles, runningProfiles: hookRunningProfiles } = useProfileEvents(); + + // Use external runningProfiles if provided, otherwise use hook's runningProfiles + const runningProfiles = externalRunningProfiles || hookRunningProfiles; + const [selectedProfile, setSelectedProfile] = useState(null); - const [isLoading, setIsLoading] = useState(false); const [isLaunching, setIsLaunching] = useState(false); const [storedProxies, setStoredProxies] = useState([]); const [launchingProfiles, setLaunchingProfiles] = useState>( @@ -77,47 +82,15 @@ export function ProfileSelectorDialog({ [storedProxies], ); - const loadProfiles = useCallback(async () => { - setIsLoading(true); + // Load stored proxies + const loadStoredProxies = useCallback(async () => { try { - // Load both profiles and stored proxies - const [profileList, proxiesList] = await Promise.all([ - invoke("list_browser_profiles"), - invoke("get_stored_proxies"), - ]); - - // Sort profiles by name - profileList.sort((a, b) => a.name.localeCompare(b.name)); - - // Set both profiles and proxies - setProfiles(profileList); + const proxiesList = await invoke("get_stored_proxies"); setStoredProxies(proxiesList); - - // Auto-select first available profile for link opening - if (profileList.length > 0) { - // First, try to find a running profile that can be used for opening links - const runningAvailableProfile = profileList.find((profile) => { - const isRunning = runningProfiles.has(profile.id); - // Simple check without browserState dependency - return ( - isRunning && - profile.browser !== "tor-browser" && - profile.browser !== "mullvad-browser" - ); - }); - - if (runningAvailableProfile) { - setSelectedProfile(runningAvailableProfile.name); - } else { - setSelectedProfile(profileList[0].name); - } - } } catch (err) { - console.error("Failed to load profiles:", err); - } finally { - setIsLoading(false); + console.error("Failed to load stored proxies:", err); } - }, [runningProfiles]); + }, []); // Helper function to get tooltip content for profiles - now uses shared hook const getProfileTooltipContent = (profile: BrowserProfile): string | null => { @@ -183,11 +156,38 @@ export function ProfileSelectorDialog({ return getProfileTooltipContent(selectedProfileData); }; + // Auto-select first available profile when dialog opens and profiles are loaded + useEffect(() => { + if (isOpen && profiles.length > 0 && !selectedProfile) { + // First, try to find a running profile that can be used for opening links + const runningAvailableProfile = profiles.find((profile) => { + const isRunning = runningProfiles.has(profile.id); + // Simple check without browserState dependency + return ( + isRunning && + profile.browser !== "tor-browser" && + profile.browser !== "mullvad-browser" + ); + }); + + if (runningAvailableProfile) { + setSelectedProfile(runningAvailableProfile.name); + } else { + // Sort profiles by name and select first + const sortedProfiles = [...profiles].sort((a, b) => + a.name.localeCompare(b.name), + ); + setSelectedProfile(sortedProfiles[0].name); + } + } + }, [isOpen, profiles, selectedProfile, runningProfiles]); + + // Load stored proxies when dialog opens useEffect(() => { if (isOpen) { - void loadProfiles(); + void loadStoredProxies(); } - }, [isOpen, loadProfiles]); + }, [isOpen, loadStoredProxies]); return ( @@ -219,11 +219,7 @@ export function ProfileSelectorDialog({
- {isLoading ? ( -
- Loading profiles... -
- ) : profiles.length === 0 ? ( + {profiles.length === 0 ? (
No profiles available. Please create a profile first. diff --git a/src/hooks/use-profile-events.ts b/src/hooks/use-profile-events.ts new file mode 100644 index 0000000..b0034cf --- /dev/null +++ b/src/hooks/use-profile-events.ts @@ -0,0 +1,185 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useState } from "react"; +import type { BrowserProfile, GroupWithCount } from "@/types"; + +interface UseProfileEventsReturn { + profiles: BrowserProfile[]; + groups: GroupWithCount[]; + runningProfiles: Set; + isLoading: boolean; + error: string | null; + loadProfiles: () => Promise; + loadGroups: () => Promise; + clearError: () => void; +} + +/** + * Custom hook to manage profile-related state and listen for backend events. + * This hook eliminates the need for manual UI refreshes by automatically + * updating state when the backend emits profile change events. + */ +export function useProfileEvents(): UseProfileEventsReturn { + const [profiles, setProfiles] = useState([]); + const [groups, setGroups] = useState([]); + const [runningProfiles, setRunningProfiles] = useState>( + new Set(), + ); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Load profiles from backend + const loadProfiles = useCallback(async () => { + try { + const profileList = await invoke( + "list_browser_profiles", + ); + setProfiles(profileList); + setError(null); + } catch (err: unknown) { + console.error("Failed to load profiles:", err); + setError(`Failed to load profiles: ${JSON.stringify(err)}`); + } + }, []); + + // Load groups from backend + const loadGroups = useCallback(async () => { + try { + const groupsWithCounts = await invoke( + "get_groups_with_profile_counts", + ); + setGroups(groupsWithCounts); + setError(null); + } catch (err) { + console.error("Failed to load groups with counts:", err); + setGroups([]); + } + }, []); + + // Clear error state + const clearError = useCallback(() => { + setError(null); + }, []); + + // Initial load and event listeners setup + useEffect(() => { + let profilesUnlisten: (() => void) | undefined; + let runningUnlisten: (() => void) | undefined; + + const setupListeners = async () => { + try { + // Initial load + await Promise.all([loadProfiles(), loadGroups()]); + + // Listen for profile changes (create, delete, rename, update, etc.) + profilesUnlisten = await listen("profiles-changed", () => { + console.log( + "Received profiles-changed event, reloading profiles and groups", + ); + void loadProfiles(); + void loadGroups(); + }); + + // Listen for profile running state changes + runningUnlisten = await listen<{ id: string; is_running: boolean }>( + "profile-running-changed", + (event) => { + const { id, is_running } = event.payload; + setRunningProfiles((prev) => { + const next = new Set(prev); + if (is_running) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + }, + ); + + console.log("Profile event listeners set up successfully"); + } catch (err) { + console.error("Failed to setup profile event listeners:", err); + setError( + `Failed to setup profile event listeners: ${JSON.stringify(err)}`, + ); + } finally { + setIsLoading(false); + } + }; + + void setupListeners(); + + // Cleanup listeners on unmount + return () => { + if (profilesUnlisten) profilesUnlisten(); + if (runningUnlisten) runningUnlisten(); + }; + }, [loadProfiles, loadGroups]); + + // Sync profile running states periodically to ensure consistency + useEffect(() => { + const syncRunningStates = async () => { + if (profiles.length === 0) return; + + try { + const statusChecks = profiles.map(async (profile) => { + try { + const isRunning = await invoke("check_browser_status", { + profile, + }); + return { id: profile.id, isRunning }; + } catch (error) { + console.error( + `Failed to check status for profile ${profile.name}:`, + error, + ); + return { id: profile.id, isRunning: false }; + } + }); + + const statuses = await Promise.all(statusChecks); + + setRunningProfiles((prev) => { + const next = new Set(prev); + let hasChanges = false; + + statuses.forEach(({ id, isRunning }) => { + if (isRunning && !prev.has(id)) { + next.add(id); + hasChanges = true; + } else if (!isRunning && prev.has(id)) { + next.delete(id); + hasChanges = true; + } + }); + + return hasChanges ? next : prev; + }); + } catch (error) { + console.error("Failed to sync profile running states:", error); + } + }; + + // Initial sync + void syncRunningStates(); + + // Sync every 30 seconds to catch any missed events + const interval = setInterval(() => { + void syncRunningStates(); + }, 30000); + + return () => clearInterval(interval); + }, [profiles]); + + return { + profiles, + groups, + runningProfiles, + isLoading, + error, + loadProfiles, + loadGroups, + clearError, + }; +}