mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-04 12:07:49 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d70ec16706 | |||
| 5863d5549e | |||
| 4df35515ae | |||
| 59f430ec43 | |||
| 9f68a21824 | |||
| 9bf7f39c0c | |||
| d1b45778c4 | |||
| 6d6527d812 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
If you are modifying the UI, do not add random colors that are not controlled by src/lib/themes.ts file.
|
||||
@@ -6,3 +6,4 @@
|
||||
- Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
|
||||
- Anytime you change nodecar's code and try to test, recompile it with "cd nodecar && pnpm build".
|
||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless I have explicitly specified in the request otherwise.
|
||||
- If you are modifying the UI, do not add random colors that are not controlled by src/lib/themes.ts file.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
Generated
+1
-1
@@ -1075,7 +1075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
|
||||
+32
-21
@@ -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,17 @@ 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 +456,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 +464,10 @@ 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 +485,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),
|
||||
}
|
||||
@@ -537,11 +543,11 @@ async fn get_group(
|
||||
}
|
||||
|
||||
async fn create_group(
|
||||
State(_state): State<ApiServerState>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateGroupRequest>,
|
||||
) -> Result<Json<ApiGroupResponse>, StatusCode> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => match manager.create_group(request.name) {
|
||||
Ok(manager) => match manager.create_group(&state.app_handle, request.name) {
|
||||
Ok(group) => Ok(Json(ApiGroupResponse {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
@@ -555,11 +561,11 @@ async fn create_group(
|
||||
|
||||
async fn update_group(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateGroupRequest>,
|
||||
) -> Result<Json<ApiGroupResponse>, StatusCode> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => match manager.update_group(id.clone(), request.name) {
|
||||
Ok(manager) => match manager.update_group(&state.app_handle, id.clone(), request.name) {
|
||||
Ok(group) => Ok(Json(ApiGroupResponse {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
@@ -573,10 +579,10 @@ async fn update_group(
|
||||
|
||||
async fn delete_group(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => match manager.delete_group(id.clone()) {
|
||||
Ok(manager) => match manager.delete_group(&state.app_handle, id.clone()) {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
},
|
||||
@@ -629,13 +635,17 @@ async fn get_proxy(
|
||||
}
|
||||
|
||||
async fn create_proxy(
|
||||
State(_state): State<ApiServerState>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
// Convert JSON value to ProxySettings
|
||||
match serde_json::from_value(request.proxy_settings.clone()) {
|
||||
Ok(proxy_settings) => {
|
||||
match PROXY_MANAGER.create_stored_proxy(request.name.clone(), proxy_settings) {
|
||||
match PROXY_MANAGER.create_stored_proxy(
|
||||
&state.app_handle,
|
||||
request.name.clone(),
|
||||
proxy_settings,
|
||||
) {
|
||||
Ok(_) => {
|
||||
// Find the created proxy to return it
|
||||
let proxies = PROXY_MANAGER.get_stored_proxies();
|
||||
@@ -658,7 +668,7 @@ async fn create_proxy(
|
||||
|
||||
async fn update_proxy(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
let proxies = PROXY_MANAGER.get_stored_proxies();
|
||||
@@ -674,6 +684,7 @@ async fn update_proxy(
|
||||
};
|
||||
|
||||
match PROXY_MANAGER.update_stored_proxy(
|
||||
&state.app_handle,
|
||||
&id,
|
||||
Some(new_name.clone()),
|
||||
Some(new_proxy_settings.clone()),
|
||||
@@ -692,9 +703,9 @@ async fn update_proxy(
|
||||
|
||||
async fn delete_proxy(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
match PROXY_MANAGER.delete_stored_proxy(&id) {
|
||||
match PROXY_MANAGER.delete_stored_proxy(&state.app_handle, &id) {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -909,9 +909,13 @@ 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 +1059,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 +1978,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 +2002,24 @@ 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}"))
|
||||
}
|
||||
|
||||
|
||||
+52
-275
@@ -4,6 +4,7 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::Emitter;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileGroup {
|
||||
@@ -97,7 +98,11 @@ impl GroupManager {
|
||||
Ok(groups_data.groups)
|
||||
}
|
||||
|
||||
pub fn create_group(&self, name: String) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
pub fn create_group(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
// Check if group with this name already exists
|
||||
@@ -113,11 +118,17 @@ impl GroupManager {
|
||||
groups_data.groups.push(group.clone());
|
||||
self.save_groups_data(&groups_data)?;
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("groups-changed", ()) {
|
||||
eprintln!("Failed to emit groups-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
pub fn update_group(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
id: String,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
@@ -142,10 +153,20 @@ impl GroupManager {
|
||||
let updated_group = group.clone();
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("groups-changed", ()) {
|
||||
eprintln!("Failed to emit groups-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(updated_group)
|
||||
}
|
||||
|
||||
pub fn delete_group(&self, id: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub fn delete_group(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
id: String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
let initial_len = groups_data.groups.len();
|
||||
@@ -156,6 +177,12 @@ impl GroupManager {
|
||||
}
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("groups-changed", ()) {
|
||||
eprintln!("Failed to emit groups-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -203,270 +230,6 @@ lazy_static::lazy_static! {
|
||||
pub static ref GROUP_MANAGER: Mutex<GroupManager> = Mutex::new(GroupManager::new());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_group_manager() -> (GroupManager, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
// Set up a temporary home directory for testing
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
|
||||
// Use per-test isolated data directory without relying on global env vars
|
||||
let data_override = temp_dir.path().join("donutbrowser_test_data");
|
||||
let manager = GroupManager::with_data_dir_override(&data_override);
|
||||
(manager, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_manager_creation() {
|
||||
let (_manager, _temp_dir) = create_test_group_manager();
|
||||
// Test passes if no panic occurs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_get_groups() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Initially should have no groups
|
||||
let groups = manager
|
||||
.get_all_groups()
|
||||
.expect("Should be able to get groups");
|
||||
assert!(groups.is_empty(), "Should start with no groups");
|
||||
|
||||
// Create a group
|
||||
let group_name = "Test Group".to_string();
|
||||
let created_group = manager
|
||||
.create_group(group_name.clone())
|
||||
.expect("Should create group successfully");
|
||||
|
||||
assert_eq!(
|
||||
created_group.name, group_name,
|
||||
"Created group should have correct name"
|
||||
);
|
||||
assert!(
|
||||
!created_group.id.is_empty(),
|
||||
"Created group should have an ID"
|
||||
);
|
||||
|
||||
// Verify group was saved
|
||||
let groups = manager
|
||||
.get_all_groups()
|
||||
.expect("Should be able to get groups");
|
||||
assert_eq!(groups.len(), 1, "Should have one group");
|
||||
assert_eq!(
|
||||
groups[0].name, group_name,
|
||||
"Retrieved group should have correct name"
|
||||
);
|
||||
assert_eq!(
|
||||
groups[0].id, created_group.id,
|
||||
"Retrieved group should have correct ID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_duplicate_group_fails() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
let group_name = "Duplicate Group".to_string();
|
||||
|
||||
// Create first group
|
||||
let _first_group = manager
|
||||
.create_group(group_name.clone())
|
||||
.expect("Should create first group");
|
||||
|
||||
// Try to create duplicate group
|
||||
let result = manager.create_group(group_name.clone());
|
||||
assert!(result.is_err(), "Should fail to create duplicate group");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("already exists"),
|
||||
"Error should mention group already exists"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_group() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Create a group
|
||||
let original_name = "Original Name".to_string();
|
||||
let created_group = manager
|
||||
.create_group(original_name)
|
||||
.expect("Should create group");
|
||||
|
||||
// Update the group
|
||||
let new_name = "Updated Name".to_string();
|
||||
let updated_group = manager
|
||||
.update_group(created_group.id.clone(), new_name.clone())
|
||||
.expect("Should update group successfully");
|
||||
|
||||
assert_eq!(
|
||||
updated_group.name, new_name,
|
||||
"Updated group should have new name"
|
||||
);
|
||||
assert_eq!(
|
||||
updated_group.id, created_group.id,
|
||||
"Updated group should keep same ID"
|
||||
);
|
||||
|
||||
// Verify update was persisted
|
||||
let groups = manager.get_all_groups().expect("Should get groups");
|
||||
assert_eq!(groups.len(), 1, "Should still have one group");
|
||||
assert_eq!(
|
||||
groups[0].name, new_name,
|
||||
"Persisted group should have updated name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_nonexistent_group_fails() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
let result = manager.update_group("nonexistent-id".to_string(), "New Name".to_string());
|
||||
assert!(result.is_err(), "Should fail to update nonexistent group");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("not found"),
|
||||
"Error should mention group not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_group() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Create a group
|
||||
let group_name = "To Delete".to_string();
|
||||
let created_group = manager
|
||||
.create_group(group_name)
|
||||
.expect("Should create group");
|
||||
|
||||
// Verify group exists
|
||||
let groups = manager.get_all_groups().expect("Should get groups");
|
||||
assert_eq!(groups.len(), 1, "Should have one group");
|
||||
|
||||
// Delete the group
|
||||
manager
|
||||
.delete_group(created_group.id)
|
||||
.expect("Should delete group successfully");
|
||||
|
||||
// Verify group was deleted
|
||||
let groups = manager.get_all_groups().expect("Should get groups");
|
||||
assert!(groups.is_empty(), "Should have no groups after deletion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_group_fails() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
let result = manager.delete_group("nonexistent-id".to_string());
|
||||
assert!(result.is_err(), "Should fail to delete nonexistent group");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("not found"),
|
||||
"Error should mention group not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_groups_with_profile_counts() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Create test groups
|
||||
let group1 = manager
|
||||
.create_group("Group 1".to_string())
|
||||
.expect("Should create group 1");
|
||||
let _group2 = manager
|
||||
.create_group("Group 2".to_string())
|
||||
.expect("Should create group 2");
|
||||
|
||||
// Create mock profiles
|
||||
let profiles = vec![
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "Profile 1".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: Some(group1.id.clone()),
|
||||
tags: Vec::new(),
|
||||
},
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "Profile 2".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: Some(group1.id.clone()),
|
||||
tags: Vec::new(),
|
||||
},
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "Profile 3".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None, // Default group
|
||||
tags: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
let groups_with_counts = manager
|
||||
.get_groups_with_profile_counts(&profiles)
|
||||
.expect("Should get groups with counts");
|
||||
|
||||
// Should have default group + group1 + group2 (group2 has 0 profiles but should still appear)
|
||||
assert_eq!(
|
||||
groups_with_counts.len(),
|
||||
3,
|
||||
"Should include all groups, even those with 0 profiles"
|
||||
);
|
||||
|
||||
// Check default group
|
||||
let default_group = groups_with_counts
|
||||
.iter()
|
||||
.find(|g| g.id == "default")
|
||||
.expect("Should have default group");
|
||||
assert_eq!(
|
||||
default_group.count, 1,
|
||||
"Default group should have 1 profile"
|
||||
);
|
||||
|
||||
// Check group1
|
||||
let group1_with_count = groups_with_counts
|
||||
.iter()
|
||||
.find(|g| g.id == group1.id)
|
||||
.expect("Should have group1");
|
||||
assert_eq!(group1_with_count.count, 2, "Group1 should have 2 profiles");
|
||||
|
||||
// Check that group2 exists with 0 profiles
|
||||
let group2_with_count = groups_with_counts
|
||||
.iter()
|
||||
.find(|g| g.name == "Group 2")
|
||||
.expect("Should have group2 present even with 0 profiles");
|
||||
assert_eq!(group2_with_count.count, 0, "Group2 should have 0 profiles");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get groups with counts
|
||||
pub fn get_groups_with_counts(profiles: &[crate::profile::BrowserProfile]) -> Vec<GroupWithCount> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
@@ -494,44 +257,58 @@ pub async fn get_groups_with_profile_counts() -> Result<Vec<GroupWithCount>, Str
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_profile_group(name: String) -> Result<ProfileGroup, String> {
|
||||
pub async fn create_profile_group(
|
||||
app_handle: tauri::AppHandle,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.create_group(name)
|
||||
.create_group(&app_handle, name)
|
||||
.map_err(|e| format!("Failed to create group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_profile_group(group_id: String, name: String) -> Result<ProfileGroup, String> {
|
||||
pub async fn update_profile_group(
|
||||
app_handle: tauri::AppHandle,
|
||||
group_id: String,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.update_group(group_id, name)
|
||||
.update_group(&app_handle, group_id, name)
|
||||
.map_err(|e| format!("Failed to update group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_profile_group(group_id: String) -> Result<(), String> {
|
||||
pub async fn delete_profile_group(
|
||||
app_handle: tauri::AppHandle,
|
||||
group_id: String,
|
||||
) -> Result<(), String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.delete_group(group_id)
|
||||
.delete_group(&app_handle, group_id)
|
||||
.map_err(|e| format!("Failed to delete group: {e}"))
|
||||
}
|
||||
|
||||
#[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}"))
|
||||
}
|
||||
|
||||
@@ -174,11 +174,12 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_stored_proxy(
|
||||
app_handle: tauri::AppHandle,
|
||||
name: String,
|
||||
proxy_settings: crate::browser::ProxySettings,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_stored_proxy(name, proxy_settings)
|
||||
.create_stored_proxy(&app_handle, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to create stored proxy: {e}"))
|
||||
}
|
||||
|
||||
@@ -189,19 +190,20 @@ async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>,
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_stored_proxy(
|
||||
app_handle: tauri::AppHandle,
|
||||
proxy_id: String,
|
||||
name: Option<String>,
|
||||
proxy_settings: Option<crate::browser::ProxySettings>,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_stored_proxy(&proxy_id, name, proxy_settings)
|
||||
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_stored_proxy(proxy_id: String) -> Result<(), String> {
|
||||
async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) -> Result<(), String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.delete_stored_proxy(&proxy_id)
|
||||
.delete_stored_proxy(&app_handle, &proxy_id)
|
||||
.map_err(|e| format!("Failed to delete stored proxy: {e}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 @@ 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 +568,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 +643,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
use crate::browser::ProxySettings;
|
||||
@@ -146,6 +147,7 @@ impl ProxyManager {
|
||||
// Create a new stored proxy
|
||||
pub fn create_stored_proxy(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
name: String,
|
||||
proxy_settings: ProxySettings,
|
||||
) -> Result<StoredProxy, String> {
|
||||
@@ -168,6 +170,11 @@ impl ProxyManager {
|
||||
eprintln!("Warning: Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("proxies-changed", ()) {
|
||||
eprintln!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(stored_proxy)
|
||||
}
|
||||
|
||||
@@ -182,6 +189,7 @@ impl ProxyManager {
|
||||
// Update a stored proxy
|
||||
pub fn update_stored_proxy(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
proxy_id: &str,
|
||||
name: Option<String>,
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
@@ -226,11 +234,20 @@ impl ProxyManager {
|
||||
eprintln!("Warning: Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("proxies-changed", ()) {
|
||||
eprintln!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(updated_proxy)
|
||||
}
|
||||
|
||||
// Delete a stored proxy
|
||||
pub fn delete_stored_proxy(&self, proxy_id: &str) -> Result<(), String> {
|
||||
pub fn delete_stored_proxy(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
proxy_id: &str,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
if stored_proxies.remove(proxy_id).is_none() {
|
||||
@@ -242,6 +259,11 @@ impl ProxyManager {
|
||||
eprintln!("Warning: Failed to delete proxy file: {e}");
|
||||
}
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("proxies-changed", ()) {
|
||||
eprintln!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -514,6 +536,11 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("proxies-changed", ()) {
|
||||
eprintln!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -554,6 +581,11 @@ impl ProxyManager {
|
||||
let _ = self.stop_proxy(app_handle.clone(), *dead_pid).await;
|
||||
}
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("proxies-changed", ()) {
|
||||
eprintln!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(dead_pids)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut Browser",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.1",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+125
-237
@@ -18,12 +18,15 @@ import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
|
||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import { useGroupEvents } from "@/hooks/use-group-events";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-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 +47,23 @@ 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,
|
||||
runningProfiles,
|
||||
isLoading: profilesLoading,
|
||||
error: profilesError,
|
||||
} = useProfileEvents();
|
||||
|
||||
const {
|
||||
groups: groupsData,
|
||||
isLoading: groupsLoading,
|
||||
error: groupsError,
|
||||
} = useGroupEvents();
|
||||
|
||||
const { isLoading: proxiesLoading, error: proxiesError } = useProxyEvents();
|
||||
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
|
||||
@@ -67,8 +85,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 +92,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 +161,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 +168,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(
|
||||
@@ -251,29 +201,9 @@ export default function Home() {
|
||||
);
|
||||
|
||||
// Auto-update functionality - use the existing hook for compatibility
|
||||
const updateNotifications = useUpdateNotifications(loadProfiles);
|
||||
const updateNotifications = useUpdateNotifications();
|
||||
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 +250,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 +263,27 @@ export default function Home() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle profile errors from useProfileEvents hook
|
||||
useEffect(() => {
|
||||
if (profilesError) {
|
||||
showErrorToast(profilesError);
|
||||
}
|
||||
}, [profilesError]);
|
||||
|
||||
// Handle group errors from useGroupEvents hook
|
||||
useEffect(() => {
|
||||
if (groupsError) {
|
||||
showErrorToast(groupsError);
|
||||
}
|
||||
}, [groupsError]);
|
||||
|
||||
// Handle proxy errors from useProxyEvents hook
|
||||
useEffect(() => {
|
||||
if (proxiesError) {
|
||||
showErrorToast(proxiesError);
|
||||
}
|
||||
}, [proxiesError]);
|
||||
|
||||
const checkAllPermissions = useCallback(async () => {
|
||||
try {
|
||||
// Wait for permissions to be initialized before checking
|
||||
@@ -390,7 +340,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 +376,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 +404,6 @@ export default function Home() {
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
groupId?: string;
|
||||
}) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await invoke<BrowserProfile>("create_browser_profile_new", {
|
||||
name: profileData.name,
|
||||
@@ -483,11 +417,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 +427,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 +441,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 +539,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 +559,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 +594,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 +606,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 +648,6 @@ export default function Home() {
|
||||
}
|
||||
}, [profiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
showErrorToast(error);
|
||||
setError(null);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Check permissions when they are initialized
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
@@ -778,6 +664,9 @@ export default function Home() {
|
||||
return profiles.filter((profile) => profile.group_id === selectedGroupId);
|
||||
}, [profiles, selectedGroupId]);
|
||||
|
||||
// Update loading states
|
||||
const isLoading = profilesLoading || groupsLoading || proxiesLoading;
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-background">
|
||||
<main className="flex flex-col row-start-2 gap-6 items-center w-full max-w-3xl">
|
||||
@@ -797,8 +686,8 @@ export default function Home() {
|
||||
<GroupBadges
|
||||
selectedGroupId={selectedGroupId}
|
||||
onGroupSelect={handleSelectGroup}
|
||||
groups={groups}
|
||||
isLoading={areGroupsLoading}
|
||||
groups={groupsData}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<ProfilesDataTable
|
||||
profiles={filteredProfiles}
|
||||
@@ -851,7 +740,6 @@ export default function Home() {
|
||||
onClose={() => {
|
||||
setImportProfileDialogOpen(false);
|
||||
}}
|
||||
onImportComplete={() => void loadProfiles()}
|
||||
/>
|
||||
|
||||
<ProxyManagementDialog
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -27,8 +26,9 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useBrowserDownload } from "@/hooks/use-browser-download";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
|
||||
import type { BrowserReleaseTypes, CamoufoxConfig } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
type BrowserTypeString =
|
||||
@@ -122,7 +122,7 @@ export function CreateProfileDialog({
|
||||
});
|
||||
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const { storedProxies } = useProxyEvents();
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
|
||||
@@ -145,15 +145,6 @@ export function CreateProfileDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadStoredProxies = useCallback(async () => {
|
||||
try {
|
||||
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(proxies);
|
||||
} catch (error) {
|
||||
console.error("Failed to load stored proxies:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkAndDownloadGeoIPDatabase = useCallback(async () => {
|
||||
try {
|
||||
const isAvailable = await invoke<boolean>("is_geoip_database_available");
|
||||
@@ -241,7 +232,6 @@ export function CreateProfileDialog({
|
||||
setSelectedBrowser("camoufox");
|
||||
}
|
||||
void loadSupportedBrowsers();
|
||||
void loadStoredProxies();
|
||||
// Load camoufox release types when dialog opens
|
||||
void loadReleaseTypes(selectedBrowser || "camoufox");
|
||||
// Check and download GeoIP database if needed for Camoufox
|
||||
@@ -250,7 +240,6 @@ export function CreateProfileDialog({
|
||||
}, [
|
||||
isOpen,
|
||||
loadSupportedBrowsers,
|
||||
loadStoredProxies,
|
||||
loadReleaseTypes,
|
||||
checkAndDownloadGeoIPDatabase,
|
||||
selectedBrowser,
|
||||
@@ -642,11 +631,6 @@ export function CreateProfileDialog({
|
||||
<ProxyFormDialog
|
||||
isOpen={showProxyForm}
|
||||
onClose={() => setShowProxyForm(false)}
|
||||
onSave={(proxy) => {
|
||||
setStoredProxies((prev) => [...prev, proxy]);
|
||||
setSelectedProxyId(proxy.id);
|
||||
void emit("stored-proxies-changed");
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -31,13 +31,11 @@ import { RippleButton } from "./ui/ripple";
|
||||
interface ImportProfileDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onImportComplete?: () => void;
|
||||
}
|
||||
|
||||
export function ImportProfileDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onImportComplete,
|
||||
}: ImportProfileDialogProps) {
|
||||
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
|
||||
[],
|
||||
@@ -140,9 +138,6 @@ export function ImportProfileDialog({
|
||||
toast.success(
|
||||
`Successfully imported profile "${autoDetectProfileName.trim()}"`,
|
||||
);
|
||||
if (onImportComplete) {
|
||||
onImportComplete();
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to import profile:", error);
|
||||
@@ -168,7 +163,6 @@ export function ImportProfileDialog({
|
||||
selectedDetectedProfile,
|
||||
autoDetectProfileName,
|
||||
detectedProfiles,
|
||||
onImportComplete,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
@@ -193,9 +187,6 @@ export function ImportProfileDialog({
|
||||
toast.success(
|
||||
`Successfully imported profile "${manualProfileName.trim()}"`,
|
||||
);
|
||||
if (onImportComplete) {
|
||||
onImportComplete();
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to import profile:", error);
|
||||
@@ -217,13 +208,7 @@ export function ImportProfileDialog({
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [
|
||||
manualBrowserType,
|
||||
manualProfilePath,
|
||||
manualProfileName,
|
||||
onImportComplete,
|
||||
onClose,
|
||||
]);
|
||||
}, [manualBrowserType, manualProfilePath, manualProfileName, onClose]);
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedDetectedProfile(null);
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useTableSorting } from "@/hooks/use-table-sorting";
|
||||
import {
|
||||
getBrowserDisplayName,
|
||||
@@ -413,10 +414,8 @@ export function ProfilesDataTable({
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const [storedProxies, setStoredProxies] = React.useState<StoredProxy[]>([]);
|
||||
const [openProxySelectorFor, setOpenProxySelectorFor] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const { storedProxies } = useProxyEvents();
|
||||
|
||||
const [proxyOverrides, setProxyOverrides] = React.useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
@@ -428,6 +427,9 @@ export function ProfilesDataTable({
|
||||
const [openTagsEditorFor, setOpenTagsEditorFor] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [openProxySelectorFor, setOpenProxySelectorFor] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const loadAllTags = React.useCallback(async () => {
|
||||
try {
|
||||
@@ -466,16 +468,6 @@ export function ProfilesDataTable({
|
||||
stoppingProfiles,
|
||||
);
|
||||
|
||||
// Load stored proxies
|
||||
const loadStoredProxies = React.useCallback(async () => {
|
||||
try {
|
||||
const proxiesList = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(proxiesList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load stored proxies:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Clear launching/stopping spinners when backend reports running status changes
|
||||
React.useEffect(() => {
|
||||
if (!browserState.isClient) return;
|
||||
@@ -511,12 +503,6 @@ export function ProfilesDataTable({
|
||||
};
|
||||
}, [browserState.isClient]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (browserState.isClient) {
|
||||
void loadStoredProxies();
|
||||
}
|
||||
}, [browserState.isClient, loadStoredProxies]);
|
||||
|
||||
// Keep stored proxies up-to-date by listening for changes emitted elsewhere in the app
|
||||
React.useEffect(() => {
|
||||
if (!browserState.isClient) return;
|
||||
@@ -524,10 +510,7 @@ export function ProfilesDataTable({
|
||||
(async () => {
|
||||
try {
|
||||
unlisten = await listen("stored-proxies-changed", () => {
|
||||
void loadStoredProxies();
|
||||
});
|
||||
// Also refresh tags on profile updates
|
||||
await listen("profile-updated", () => {
|
||||
// Also refresh tags on profile updates
|
||||
void loadAllTags();
|
||||
});
|
||||
} catch (_err) {
|
||||
@@ -537,7 +520,7 @@ export function ProfilesDataTable({
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [browserState.isClient, loadStoredProxies, loadAllTags]);
|
||||
}, [browserState.isClient, loadAllTags]);
|
||||
|
||||
// Automatically deselect profiles that become running, updating, launching, or stopping
|
||||
React.useEffect(() => {
|
||||
@@ -1308,7 +1291,9 @@ export function ProfilesDataTable({
|
||||
!profileHasProxy && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{trimName(displayName, 10)}
|
||||
{profileHasProxy
|
||||
? trimName(displayName, 10)
|
||||
: displayName}
|
||||
</span>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type { BrowserProfile, StoredProxy } from "@/types";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProfileSelectorDialogProps {
|
||||
@@ -43,14 +45,19 @@ 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 { storedProxies } = useProxyEvents();
|
||||
|
||||
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>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -77,48 +84,6 @@ export function ProfileSelectorDialog({
|
||||
[storedProxies],
|
||||
);
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}, [runningProfiles]);
|
||||
|
||||
// Helper function to get tooltip content for profiles - now uses shared hook
|
||||
const getProfileTooltipContent = (profile: BrowserProfile): string | null => {
|
||||
return browserState.getProfileTooltipContent(profile);
|
||||
@@ -183,11 +148,31 @@ export function ProfileSelectorDialog({
|
||||
return getProfileTooltipContent(selectedProfileData);
|
||||
};
|
||||
|
||||
// Auto-select first available profile when dialog opens and profiles are loaded
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadProfiles();
|
||||
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, loadProfiles]);
|
||||
}, [isOpen, profiles, selectedProfile, runningProfiles]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
@@ -219,11 +204,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.
|
||||
|
||||
@@ -35,14 +35,12 @@ interface ProxyFormData {
|
||||
interface ProxyFormDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (proxy: StoredProxy) => void;
|
||||
editingProxy?: StoredProxy | null;
|
||||
}
|
||||
|
||||
export function ProxyFormDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
editingProxy,
|
||||
}: ProxyFormDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -105,11 +103,9 @@ export function ProxyFormDialog({
|
||||
password: formData.password.trim() || undefined,
|
||||
};
|
||||
|
||||
let savedProxy: StoredProxy;
|
||||
|
||||
if (editingProxy) {
|
||||
// Update existing proxy
|
||||
savedProxy = await invoke<StoredProxy>("update_stored_proxy", {
|
||||
await invoke("update_stored_proxy", {
|
||||
proxyId: editingProxy.id,
|
||||
name: formData.name.trim(),
|
||||
proxySettings,
|
||||
@@ -117,14 +113,13 @@ export function ProxyFormDialog({
|
||||
toast.success("Proxy updated successfully");
|
||||
} else {
|
||||
// Create new proxy
|
||||
savedProxy = await invoke<StoredProxy>("create_stored_proxy", {
|
||||
await invoke("create_stored_proxy", {
|
||||
name: formData.name.trim(),
|
||||
proxySettings,
|
||||
});
|
||||
toast.success("Proxy created successfully");
|
||||
}
|
||||
|
||||
onSave(savedProxy);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save proxy:", error);
|
||||
@@ -134,7 +129,7 @@ export function ProxyFormDialog({
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, editingProxy, onSave, onClose]);
|
||||
}, [formData, editingProxy, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useState } from "react";
|
||||
import { FiEdit2, FiPlus, FiTrash2, FiWifi } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { trimName } from "@/lib/name-utils";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
@@ -34,84 +35,12 @@ export function ProxyManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ProxyManagementDialogProps) {
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
|
||||
const [proxyUsage, setProxyUsage] = useState<Record<string, number>>({});
|
||||
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const loadStoredProxies = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(proxies);
|
||||
} catch (error) {
|
||||
console.error("Failed to load stored proxies:", error);
|
||||
toast.error("Failed to load proxies");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadProxyUsage = useCallback(async () => {
|
||||
try {
|
||||
const profiles = await invoke<Array<{ proxy_id?: string }>>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const p of profiles) {
|
||||
if (p.proxy_id) counts[p.proxy_id] = (counts[p.proxy_id] ?? 0) + 1;
|
||||
}
|
||||
setProxyUsage(counts);
|
||||
} catch (_err) {
|
||||
// ignore non-critical errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadStoredProxies();
|
||||
void loadProxyUsage();
|
||||
}
|
||||
}, [isOpen, loadStoredProxies, loadProxyUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const setup = async () => {
|
||||
try {
|
||||
unlisten = await listen("profile-updated", () => {
|
||||
void loadProxyUsage();
|
||||
});
|
||||
} catch (_err) {
|
||||
// ignore non-critical errors
|
||||
}
|
||||
};
|
||||
if (isOpen) void setup();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [isOpen, loadProxyUsage]);
|
||||
|
||||
// Keep list in sync with external changes (e.g., created from CreateProfileDialog)
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const setup = async () => {
|
||||
try {
|
||||
unlisten = await listen("stored-proxies-changed", () => {
|
||||
void loadStoredProxies();
|
||||
void loadProxyUsage();
|
||||
});
|
||||
} catch (_err) {
|
||||
// ignore non-critical errors
|
||||
}
|
||||
};
|
||||
if (isOpen) void setup();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [isOpen, loadStoredProxies, loadProxyUsage]);
|
||||
const { storedProxies, proxyUsage, isLoading } = useProxyEvents();
|
||||
|
||||
const handleDeleteProxy = useCallback((proxy: StoredProxy) => {
|
||||
// Open in-app confirmation dialog
|
||||
@@ -123,7 +52,6 @@ export function ProxyManagementDialog({
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await invoke("delete_stored_proxy", { proxyId: proxyToDelete.id });
|
||||
setStoredProxies((prev) => prev.filter((p) => p.id !== proxyToDelete.id));
|
||||
toast.success("Proxy deleted successfully");
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
@@ -145,24 +73,6 @@ export function ProxyManagementDialog({
|
||||
setShowProxyForm(true);
|
||||
}, []);
|
||||
|
||||
const handleProxySaved = useCallback((savedProxy: StoredProxy) => {
|
||||
setStoredProxies((prev) => {
|
||||
const existingIndex = prev.findIndex((p) => p.id === savedProxy.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing proxy
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = savedProxy;
|
||||
return updated;
|
||||
} else {
|
||||
// Add new proxy
|
||||
return [...prev, savedProxy];
|
||||
}
|
||||
});
|
||||
setShowProxyForm(false);
|
||||
setEditingProxy(null);
|
||||
void emit("stored-proxies-changed");
|
||||
}, []);
|
||||
|
||||
const handleProxyFormClose = useCallback(() => {
|
||||
setShowProxyForm(false);
|
||||
setEditingProxy(null);
|
||||
@@ -200,13 +110,12 @@ export function ProxyManagementDialog({
|
||||
|
||||
{/* Proxy List - Scrollable */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
</p>
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center py-6">
|
||||
<div className="w-8 h-8 rounded-full border-b-2 animate-spin border-primary"></div>
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
)}
|
||||
{storedProxies.length === 0 && !isLoading ? (
|
||||
<div className="flex flex-col justify-center items-center h-32 text-center">
|
||||
<FiWifi className="mx-auto mb-4 w-12 h-12 text-muted-foreground" />
|
||||
<p className="mb-2 text-muted-foreground">
|
||||
@@ -310,7 +219,6 @@ export function ProxyManagementDialog({
|
||||
<ProxyFormDialog
|
||||
isOpen={showProxyForm}
|
||||
onClose={handleProxyFormClose}
|
||||
onSave={handleProxySaved}
|
||||
editingProxy={editingProxy}
|
||||
/>
|
||||
<DeleteConfirmationDialog
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
|
||||
/**
|
||||
* Custom hook to manage group-related state and listen for backend events.
|
||||
* This hook eliminates the need for manual UI refreshes by automatically
|
||||
* updating state when the backend emits group change events.
|
||||
*/
|
||||
export function useGroupEvents() {
|
||||
const [groups, setGroups] = useState<GroupWithCount[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 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: unknown) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(`Failed to load groups: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Clear error state
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Initial load and event listeners setup
|
||||
useEffect(() => {
|
||||
let groupsUnlisten: (() => void) | undefined;
|
||||
|
||||
const setupListeners = async () => {
|
||||
try {
|
||||
// Initial load
|
||||
await loadGroups();
|
||||
|
||||
// Listen for group changes (create, delete, rename, update, etc.)
|
||||
groupsUnlisten = await listen("groups-changed", () => {
|
||||
console.log("Received groups-changed event, reloading groups");
|
||||
void loadGroups();
|
||||
});
|
||||
|
||||
// Also listen for profile changes since groups show profile counts
|
||||
const profilesUnlisten = await listen("profiles-changed", () => {
|
||||
console.log(
|
||||
"Received profiles-changed event, reloading groups for updated counts",
|
||||
);
|
||||
void loadGroups();
|
||||
});
|
||||
|
||||
// Store both listeners for cleanup
|
||||
groupsUnlisten = () => {
|
||||
groupsUnlisten?.();
|
||||
profilesUnlisten();
|
||||
};
|
||||
|
||||
console.log("Group event listeners set up successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to setup group event listeners:", err);
|
||||
setError(
|
||||
`Failed to setup group event listeners: ${JSON.stringify(err)}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void setupListeners();
|
||||
|
||||
// Cleanup listeners on unmount
|
||||
return () => {
|
||||
if (groupsUnlisten) groupsUnlisten();
|
||||
};
|
||||
}, [loadGroups]);
|
||||
|
||||
return {
|
||||
groups,
|
||||
isLoading,
|
||||
error,
|
||||
loadGroups,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { StoredProxy } from "@/types";
|
||||
|
||||
/**
|
||||
* Custom hook to manage proxy-related state and listen for backend events.
|
||||
* This hook eliminates the need for manual UI refreshes by automatically
|
||||
* updating state when the backend emits proxy change events.
|
||||
*/
|
||||
export function useProxyEvents() {
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const [proxyUsage, setProxyUsage] = useState<Record<string, number>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load proxy usage (how many profiles are using each proxy)
|
||||
const loadProxyUsage = useCallback(async () => {
|
||||
try {
|
||||
const profiles = await invoke<Array<{ proxy_id?: string }>>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const p of profiles) {
|
||||
if (p.proxy_id) counts[p.proxy_id] = (counts[p.proxy_id] ?? 0) + 1;
|
||||
}
|
||||
setProxyUsage(counts);
|
||||
} catch (err) {
|
||||
console.error("Failed to load proxy usage:", err);
|
||||
// Don't set error for non-critical proxy usage
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load proxies from backend
|
||||
const loadProxies = useCallback(async () => {
|
||||
try {
|
||||
const stored = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(stored);
|
||||
await loadProxyUsage();
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load proxies:", err);
|
||||
setError(`Failed to load proxies: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, [loadProxyUsage]);
|
||||
|
||||
// Clear error state
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Initial load and event listeners setup
|
||||
useEffect(() => {
|
||||
let proxiesUnlisten: (() => void) | undefined;
|
||||
let profilesUnlisten: (() => void) | undefined;
|
||||
let storedProxiesUnlisten: (() => void) | undefined;
|
||||
|
||||
const setupListeners = async () => {
|
||||
try {
|
||||
// Initial load
|
||||
await loadProxies();
|
||||
|
||||
// Listen for proxy changes (create, delete, update, start, stop, etc.)
|
||||
proxiesUnlisten = await listen("proxies-changed", () => {
|
||||
console.log("Received proxies-changed event, reloading proxies");
|
||||
void loadProxies();
|
||||
});
|
||||
|
||||
// Listen for profile changes to update proxy usage counts
|
||||
profilesUnlisten = await listen("profiles-changed", () => {
|
||||
console.log("Received profiles-changed event, reloading proxy usage");
|
||||
void loadProxyUsage();
|
||||
});
|
||||
|
||||
// Listen for profile updates to update proxy usage counts
|
||||
storedProxiesUnlisten = await listen("stored-proxies-changed", () => {
|
||||
console.log(
|
||||
"Received stored-proxies-changed event, reloading proxies",
|
||||
);
|
||||
void loadProxies();
|
||||
});
|
||||
|
||||
console.log("Proxy event listeners set up successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to setup proxy event listeners:", err);
|
||||
setError(
|
||||
`Failed to setup proxy event listeners: ${JSON.stringify(err)}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void setupListeners();
|
||||
|
||||
// Cleanup listeners on unmount
|
||||
return () => {
|
||||
if (proxiesUnlisten) proxiesUnlisten();
|
||||
if (profilesUnlisten) profilesUnlisten();
|
||||
if (storedProxiesUnlisten) storedProxiesUnlisten();
|
||||
};
|
||||
}, [loadProxies, loadProxyUsage]);
|
||||
|
||||
return {
|
||||
storedProxies,
|
||||
proxyUsage,
|
||||
isLoading,
|
||||
error,
|
||||
loadProxies,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user