refactor: update ui on profile events

This commit is contained in:
zhom
2025-08-18 16:54:36 +04:00
parent c30df278fb
commit 6d6527d812
8 changed files with 419 additions and 309 deletions
+10 -10
View File
@@ -49,7 +49,7 @@ pub struct ApiProfileResponse {
pub struct CreateProfileRequest {
pub name: String,
pub browser: String,
pub version: Option<String>,
pub version: String,
pub proxy_id: Option<String>,
pub release_type: Option<String>,
pub camoufox_config: Option<serde_json::Value>,
@@ -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<String>,
State(_state): State<ApiServerState>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
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),
}
+9 -5
View File
@@ -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<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
@@ -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<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
// 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<Vec<String>, 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 {
+9 -8
View File
@@ -909,9 +909,9 @@ impl BrowserRunner {
})
}
pub fn delete_profile(&self, profile_id: &str) -> Result<(), Box<dyn std::error::Error>> {
pub fn delete_profile(&self, app_handle: tauri::AppHandle, profile_id: &str) -> Result<(), Box<dyn std::error::Error>> {
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<String>,
) -> Result<BrowserProfile, String> {
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<BrowserProfile, String> {
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}"))
}
+7 -3
View File
@@ -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<String>,
group_id: Option<String>,
) -> 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<String>) -> Result<(), String> {
pub async fn delete_selected_profiles(
app_handle: tauri::AppHandle,
profile_names: Vec<String>,
) -> 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}"))
}
+56 -2
View File
@@ -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<BrowserProfile, Box<dyn std::error::Error>> {
@@ -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<dyn std::error::Error>> {
pub fn delete_profile(
&self,
app_handle: &tauri::AppHandle,
profile_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
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<BrowserProfile, Box<dyn std::error::Error>> {
@@ -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<String>,
group_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
@@ -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<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
@@ -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<String>,
) -> Result<(), Box<dyn std::error::Error>> {
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)
}
+100 -234
View File
@@ -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<BrowserProfile[]>([]);
const [error, setError] = useState<string | null>(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<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
const [groups, setGroups] = useState<GroupWithCount[]>([]);
const [areGroupsLoading, setGroupsLoading] = useState(true);
const [currentPermissionType, setCurrentPermissionType] =
useState<PermissionType>("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<Set<string>>(
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<boolean>("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<BrowserProfile[]>(
"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<Set<string>>(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<BrowserProfile[]>(
"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<GroupWithCount[]>(
"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<BrowserProfile>("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<boolean>("check_browser_status", {
profile,
});
try {
// First check if the browser is running for this profile
const isRunning = await invoke<boolean>("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}
/>
<ProfilesDataTable
profiles={filteredProfiles}
+43 -47
View File
@@ -27,6 +27,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useBrowserState } from "@/hooks/use-browser-state";
import { useProfileEvents } from "@/hooks/use-profile-events";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserProfile, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -43,12 +44,16 @@ export function ProfileSelectorDialog({
isOpen,
onClose,
url,
runningProfiles = new Set(),
runningProfiles: externalRunningProfiles,
isUpdating,
}: ProfileSelectorDialogProps) {
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
// 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<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLaunching, setIsLaunching] = useState(false);
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [launchingProfiles, setLaunchingProfiles] = useState<Set<string>>(
@@ -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<BrowserProfile[]>("list_browser_profiles"),
invoke<StoredProxy[]>("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<StoredProxy[]>("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 (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -219,11 +219,7 @@ export function ProfileSelectorDialog({
<div className="space-y-2">
<Label htmlFor="profile-select">Select Profile:</Label>
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading profiles...
</div>
) : profiles.length === 0 ? (
{profiles.length === 0 ? (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
No profiles available. Please create a profile first.
+185
View File
@@ -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<string>;
isLoading: boolean;
error: string | null;
loadProfiles: () => Promise<void>;
loadGroups: () => Promise<void>;
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<BrowserProfile[]>([]);
const [groups, setGroups] = useState<GroupWithCount[]>([]);
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
new Set(),
);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load profiles from backend
const loadProfiles = useCallback(async () => {
try {
const profileList = await invoke<BrowserProfile[]>(
"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<GroupWithCount[]>(
"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<boolean>("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,
};
}