Compare commits

...

9 Commits

Author SHA1 Message Date
zhom a77b733a31 chore: version bump 2025-08-14 22:39:16 +04:00
zhom c10c3b0f95 refactor: creation modal cleanup 2025-08-14 22:37:03 +04:00
zhom 4b16341401 refactor: integrage rename of profile into row 2025-08-14 22:35:44 +04:00
zhom 016d423d2c refactor: revert global listener 2025-08-14 22:01:14 +04:00
zhom 0596cc4009 style: fix toast width 2025-08-14 21:46:36 +04:00
zhom 269db678b7 chore: version bump 2025-08-13 16:29:42 +04:00
zhom f809b975f3 refactor: better ui handling for proxy changes 2025-08-13 16:28:08 +04:00
zhom e369214715 refactor: do not try to reuse old proxy port 2025-08-13 16:13:47 +04:00
zhom 5f93841bb7 style: copy 2025-08-13 15:28:05 +04:00
13 changed files with 392 additions and 366 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.9.2",
"version": "0.9.4",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
+1 -1
View File
@@ -1021,7 +1021,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.9.2"
version = "0.9.4"
dependencies = [
"async-trait",
"base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.9.2"
version = "0.9.4"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+106 -117
View File
@@ -497,19 +497,6 @@ impl ProfileManager {
format!("Profile {profile_name} not found").into()
})?;
// Check if browser is running to manage proxy accordingly
let browser_is_running = profile.process_id.is_some()
&& self
.check_browser_status(app_handle.clone(), &profile)
.await?;
// If browser is running, stop existing proxy
if browser_is_running && profile.proxy_id.is_some() {
if let Some(pid) = profile.process_id {
let _ = PROXY_MANAGER.stop_proxy(app_handle.clone(), pid).await;
}
}
// Update proxy settings
profile.proxy_id = proxy_id.clone();
@@ -520,68 +507,16 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
// Handle proxy startup/configuration
// Update on-disk browser profile config immediately
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
if browser_is_running {
// Browser is running and proxy is enabled, start new proxy
if let Some(pid) = profile.process_id {
match PROXY_MANAGER
.start_proxy(
app_handle.clone(),
Some(&proxy_settings),
pid,
Some(profile_name),
)
.await
{
Ok(internal_proxy_settings) => {
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
// Apply the proxy settings with the internal proxy to the profile directory
self
.apply_proxy_settings_to_profile(
&profile_path,
&proxy_settings,
Some(&internal_proxy_settings),
)
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
println!("Successfully started proxy for profile: {}", profile.name);
}
Err(e) => {
eprintln!("Failed to start proxy: {e}");
// Apply proxy settings without internal proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
}
}
} else {
// No PID available, apply proxy settings without internal proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
}
} else {
// Proxy disabled or browser not running, just apply settings
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
}
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
} else {
// Proxy ID provided but proxy not found, disable proxy
let profiles_dir = self.get_profiles_dir();
@@ -624,7 +559,7 @@ impl ProfileManager {
}
// For non-camoufox browsers, use the existing PID-based logic
let mut inner_profile = profile.clone();
let inner_profile = profile.clone();
let system = System::new_all();
let mut is_running = false;
let mut found_pid: Option<u32> = None;
@@ -748,24 +683,42 @@ impl ProfileManager {
let metadata_exists = metadata_file.exists();
if metadata_exists {
// Update the process ID if we found a different one
// Load the latest profile from disk to avoid overwriting fields like proxy_id
let latest_profile: BrowserProfile = match std::fs::read_to_string(&metadata_file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
{
Some(p) => p,
None => inner_profile.clone(),
};
let previous_pid = latest_profile.process_id;
let mut merged = latest_profile.clone();
if let Some(pid) = found_pid {
if inner_profile.process_id != Some(pid) {
inner_profile.process_id = Some(pid);
if let Err(e) = self.save_profile(&inner_profile) {
if merged.process_id != Some(pid) {
merged.process_id = Some(pid);
if let Err(e) = self.save_profile(&merged) {
println!("Warning: Failed to update profile with new PID: {e}");
}
}
} else if inner_profile.process_id.is_some() {
} else if merged.process_id.is_some() {
// Clear the PID if no process found
inner_profile.process_id = None;
if let Err(e) = self.save_profile(&inner_profile) {
merged.process_id = None;
if let Err(e) = self.save_profile(&merged) {
println!("Warning: Failed to clear profile PID: {e}");
}
// Stop any associated proxy immediately when the browser stops
if let Some(old_pid) = previous_pid {
let _ = crate::proxy_manager::PROXY_MANAGER
.stop_proxy(app_handle.clone(), old_pid)
.await;
}
}
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &inner_profile) {
if let Err(e) = app_handle.emit("profile-updated", &merged) {
println!("Warning: Failed to emit profile update event: {e}");
}
}
@@ -790,49 +743,71 @@ impl ProfileManager {
match launcher.find_camoufox_by_profile(&profile_path_str).await {
Ok(Some(camoufox_process)) => {
// Found a running instance, update profile with process info if changed
let process_id_changed = profile.process_id != camoufox_process.processId;
// Only write status changes if metadata still exists
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
let metadata_file = profile_uuid_dir.join("metadata.json");
let metadata_exists = metadata_file.exists();
if process_id_changed && metadata_exists {
let mut updated_profile = profile.clone();
updated_profile.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&updated_profile) {
println!("Warning: Failed to update Camoufox profile with process info: {e}");
}
if metadata_exists {
// Load latest to avoid overwriting other fields
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
{
Some(p) => p,
None => profile.clone(),
};
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
}
if latest.process_id != camoufox_process.processId {
latest.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&latest) {
println!("Warning: Failed to update Camoufox profile with process info: {e}");
}
println!(
"Camoufox process has started for profile '{}' with PID: {:?}",
profile.name, camoufox_process.processId
);
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &latest) {
println!("Warning: Failed to emit profile update event: {e}");
}
println!(
"Camoufox process has started for profile '{}' with PID: {:?}",
profile.name, camoufox_process.processId
);
}
}
Ok(true)
}
Ok(None) => {
// No running instance found, clear process ID if set
// No running instance found, clear process ID if set and stop proxy
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
let metadata_file = profile_uuid_dir.join("metadata.json");
let metadata_exists = metadata_file.exists();
if profile.process_id.is_some() && metadata_exists {
let mut updated_profile = profile.clone();
updated_profile.process_id = None;
if let Err(e) = self.save_profile(&updated_profile) {
println!("Warning: Failed to clear Camoufox profile process info: {e}");
}
if metadata_exists {
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
{
Some(p) => p,
None => profile.clone(),
};
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
if let Some(old_pid) = latest.process_id {
latest.process_id = None;
if let Err(e) = self.save_profile(&latest) {
println!("Warning: Failed to clear Camoufox profile process info: {e}");
}
// Stop any proxy tied to this old PID immediately
let _ = crate::proxy_manager::PROXY_MANAGER
.stop_proxy(app_handle.clone(), old_pid)
.await;
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &latest) {
println!("Warning: Failed to emit profile update event: {e}");
}
}
}
Ok(false)
@@ -845,16 +820,30 @@ impl ProfileManager {
let metadata_file = profile_uuid_dir.join("metadata.json");
let metadata_exists = metadata_file.exists();
if profile.process_id.is_some() && metadata_exists {
let mut updated_profile = profile.clone();
updated_profile.process_id = None;
if let Err(e) = self.save_profile(&updated_profile) {
println!("Warning: Failed to clear Camoufox profile process info after error: {e}");
}
if metadata_exists {
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
{
Some(p) => p,
None => profile.clone(),
};
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
println!("Warning: Failed to emit profile update event: {e}");
if let Some(old_pid) = latest.process_id {
latest.process_id = None;
if let Err(e2) = self.save_profile(&latest) {
println!("Warning: Failed to clear Camoufox profile process info after error: {e2}");
}
// Best-effort stop of proxy tied to old PID
let _ = crate::proxy_manager::PROXY_MANAGER
.stop_proxy(app_handle.clone(), old_pid)
.await;
// Emit profile update event to frontend
if let Err(e3) = app_handle.emit("profile-updated", &latest) {
println!("Warning: Failed to emit profile update event: {e3}");
}
}
}
Ok(false)
+24 -28
View File
@@ -350,29 +350,6 @@ impl ProxyManager {
let _ = self.stop_proxy(app_handle.clone(), browser_pid).await;
}
// Check if we have a preferred port for this profile
let preferred_port = if let Some(name) = profile_name {
let profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.get(name).and_then(|_settings| {
// Find existing proxy with same settings to reuse port
let active_proxies = self.active_proxies.lock().unwrap();
active_proxies
.values()
.find(|p| {
if let Some(proxy_settings) = proxy_settings {
p.upstream_host == proxy_settings.host
&& p.upstream_port == proxy_settings.port
&& p.upstream_type == proxy_settings.proxy_type
} else {
p.upstream_type == "DIRECT"
}
})
.map(|p| p.local_port)
})
} else {
None
};
// Start a new proxy using the nodecar binary with the correct CLI interface
let mut nodecar = app_handle
.shell()
@@ -400,11 +377,6 @@ impl ProxyManager {
}
}
// If we have a preferred port, use it
if let Some(port) = preferred_port {
nodecar = nodecar.arg("--port").arg(port.to_string());
}
// Execute the command and wait for it to complete
// The nodecar binary should start the worker and then exit
let output = nodecar
@@ -449,6 +421,30 @@ impl ProxyManager {
profile_name: profile_name.map(|s| s.to_string()),
};
// Wait for the local proxy port to be ready to accept connections
{
use tokio::net::TcpStream;
use tokio::time::{sleep, Duration};
let mut ready = false;
for _ in 0..50 {
match TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, proxy_info.local_port)).await {
Ok(_stream) => {
ready = true;
break;
}
Err(_) => {
sleep(Duration::from_millis(100)).await;
}
}
}
if !ready {
return Err(format!(
"Local proxy on 127.0.0.1:{} did not become ready in time",
proxy_info.local_port
));
}
}
// Store the proxy info
{
let mut proxies = self.active_proxies.lock().unwrap();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.9.2",
"version": "0.9.4",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
+3
View File
@@ -21,6 +21,7 @@ import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
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";
@@ -40,6 +41,8 @@ interface PendingUrl {
}
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);
+25 -18
View File
@@ -1,6 +1,7 @@
"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";
@@ -107,7 +108,7 @@ export function CreateProfileDialog({
const handleTabChange = (value: string) => {
if (value === "regular") {
setSelectedBrowser(null);
setSelectedBrowser("firefox");
} else if (value === "anti-detect") {
setSelectedBrowser("camoufox");
}
@@ -178,6 +179,8 @@ export function CreateProfileDialog({
{ browserStr: browser },
);
await loadDownloadedVersions(browser);
// Only update state if this browser is still the one we're loading
if (loadingBrowserRef.current === browser) {
// Filter to enforce stable-only creation, except Firefox Developer (nightly-only)
@@ -197,12 +200,29 @@ export function CreateProfileDialog({
filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
}
// Load downloaded versions for this browser
await loadDownloadedVersions(browser);
}
} catch (error) {
console.error(`Failed to load release types for ${browser}:`, error);
// Fallback: still load downloaded versions and derive release type from them if possible
try {
const downloaded = await loadDownloadedVersions(browser);
if (loadingBrowserRef.current === browser && downloaded.length > 0) {
const latest = downloaded[0];
const fallback: BrowserReleaseTypes = {};
if (browser === "firefox-developer") {
fallback.nightly = latest;
} else {
fallback.stable = latest;
}
setReleaseTypes(fallback);
}
} catch (e) {
console.error(
`Failed to load downloaded versions for ${browser}:`,
e,
);
}
} finally {
// Clear loading state only if we're still loading this browser
if (loadingBrowserRef.current === browser) {
@@ -386,20 +406,6 @@ export function CreateProfileDialog({
isBrowserVersionAvailable,
]);
useEffect(() => {
console.log(
selectedBrowser,
selectedBrowser && isBrowserCurrentlyDownloading(selectedBrowser),
selectedBrowser && isBrowserVersionAvailable(selectedBrowser),
selectedBrowser && getBestAvailableVersion(selectedBrowser),
);
}, [
selectedBrowser,
isBrowserCurrentlyDownloading,
isBrowserVersionAvailable,
getBestAvailableVersion,
]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-full max-h-[90vh] flex flex-col">
@@ -634,6 +640,7 @@ export function CreateProfileDialog({
onSave={(proxy) => {
setStoredProxies((prev) => [...prev, proxy]);
setSelectedProxyId(proxy.id);
void emit("stored-proxies-changed");
}}
/>
</Dialog>
+1 -1
View File
@@ -168,7 +168,7 @@ export function UnifiedToast(props: ToastProps) {
const progress = "progress" in props ? props.progress : undefined;
return (
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="flex items-start p-3 w-96 rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold leading-tight text-foreground">
+154 -82
View File
@@ -9,6 +9,7 @@ import {
useReactTable,
} from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import * as React from "react";
import { CiCircleCheck } from "react-icons/ci";
import { IoEllipsisHorizontal } from "react-icons/io5";
@@ -24,13 +25,6 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -66,8 +60,9 @@ import {
import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, StoredProxy } from "@/types";
import { LoadingButton } from "./loading-button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
// Label no longer needed after removing dialog-based renaming
import { RippleButton } from "./ui/ripple";
interface ProfilesDataTableProps {
@@ -106,6 +101,8 @@ export function ProfilesDataTable({
React.useState<BrowserProfile | null>(null);
const [newProfileName, setNewProfileName] = React.useState("");
const [renameError, setRenameError] = React.useState<string | null>(null);
const [isRenamingSaving, setIsRenamingSaving] = React.useState(false);
const renameContainerRef = React.useRef<HTMLDivElement | null>(null);
const [profileToDelete, setProfileToDelete] =
React.useState<BrowserProfile | null>(null);
const [isDeleting, setIsDeleting] = React.useState(false);
@@ -132,10 +129,12 @@ export function ProfilesDataTable({
async (profileName: string, proxyId: string | null) => {
try {
await invoke("update_profile_proxy", {
profileName: profileName,
proxy_id: proxyId,
profileName,
proxyId,
});
setProxyOverrides((prev) => ({ ...prev, [profileName]: proxyId }));
// Notify other parts of the app so usage counts and lists refresh
await emit("profile-updated");
} catch (error) {
console.error("Failed to update proxy settings:", error);
} finally {
@@ -179,6 +178,24 @@ export function ProfilesDataTable({
}
}, [browserState.isClient, loadStoredProxies]);
// Keep stored proxies up-to-date by listening for changes emitted elsewhere in the app
React.useEffect(() => {
if (!browserState.isClient) return;
let unlisten: (() => void) | undefined;
(async () => {
try {
unlisten = await listen("stored-proxies-changed", () => {
void loadStoredProxies();
});
} catch (_err) {
// Best-effort only
}
})();
return () => {
if (unlisten) unlisten();
};
}, [browserState.isClient, loadStoredProxies]);
// Automatically deselect profiles that become running, updating, launching, or stopping
React.useEffect(() => {
setSelectedProfiles((prev) => {
@@ -244,10 +261,11 @@ export function ProfilesDataTable({
[browserState.isClient, sorting, updateSorting],
);
const handleRename = async () => {
const handleRename = React.useCallback(async () => {
if (!profileToRename || !newProfileName.trim()) return;
try {
setIsRenamingSaving(true);
await onRenameProfile(profileToRename.name, newProfileName.trim());
setProfileToRename(null);
setNewProfileName("");
@@ -256,8 +274,31 @@ export function ProfilesDataTable({
setRenameError(
error instanceof Error ? error.message : "Failed to rename profile",
);
} finally {
setIsRenamingSaving(false);
}
};
}, [profileToRename, newProfileName, onRenameProfile]);
// Cancel inline rename on outside click
React.useEffect(() => {
if (!profileToRename) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null;
if (
target &&
renameContainerRef.current &&
!renameContainerRef.current.contains(target)
) {
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [profileToRename]);
const handleDelete = async () => {
if (!profileToDelete) return;
@@ -601,20 +642,103 @@ export function ProfilesDataTable({
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const profile = row.original as BrowserProfile;
const rawName: string = row.getValue("name");
const name = getBrowserDisplayName(rawName);
const isEditing = profileToRename?.name === profile.name;
if (name.length < 20) {
return <div className="font-medium text-left">{name}</div>;
if (isEditing) {
const isSaveDisabled =
isRenamingSaving ||
newProfileName.trim().length === 0 ||
newProfileName.trim() === profile.name;
return (
<div
ref={renameContainerRef}
className="overflow-visible relative"
>
<Input
autoFocus
value={newProfileName}
onChange={(e) => {
setNewProfileName(e.target.value);
if (renameError) setRenameError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleRename();
} else if (e.key === "Escape") {
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
}
}}
className="inline-block w-full"
/>
<div className="flex absolute right-0 top-full z-50 gap-1 translate-y-[30%] bg-primary-foreground opacity-100">
<LoadingButton
isLoading={isRenamingSaving}
size="sm"
variant="default"
disabled={isSaveDisabled}
className="cursor-pointer"
onClick={() => void handleRename()}
>
Save
</LoadingButton>
</div>
</div>
);
}
const display =
name.length < 20 ? (
<div className="font-medium text-left">{name}</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>{trimName(name, 20)}</span>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
);
const isRunning =
browserState.isClient && runningProfiles.has(profile.name);
const isLaunching = launchingProfiles.has(profile.name);
const isStopping = stoppingProfiles.has(profile.name);
const isBrowserUpdating = isUpdating(profile.browser);
const isDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
return (
<Tooltip>
<TooltipTrigger asChild>
<span>{trimName(name, 20)}</span>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
<button
type="button"
className={cn(
"p-2 mr-auto w-full text-left bg-transparent rounded border-none",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
)}
onClick={() => {
if (isDisabled) return;
setProfileToRename(profile);
setNewProfileName(profile.name);
setRenameError(null);
}}
onKeyDown={(e) => {
if (isDisabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setProfileToRename(profile);
setNewProfileName(profile.name);
setRenameError(null);
}
}}
>
{display}
</button>
);
},
},
@@ -729,7 +853,7 @@ export function ProfilesDataTable({
<PopoverTrigger asChild>
<span
className={cn(
"flex gap-2 items-center px-1 rounded",
"flex gap-2 items-center p-2 rounded",
isDisabled
? "opacity-60 cursor-not-allowed pointer-events-none"
: "cursor-pointer hover:bg-accent/50",
@@ -851,15 +975,7 @@ export function ProfilesDataTable({
Configure Fingerprint
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setProfileToRename(profile);
setNewProfileName(profile.name);
}}
disabled={isDisabled}
>
Rename
</DropdownMenuItem>
{/* Rename removed from menu; inline on name click */}
<DropdownMenuItem
onClick={() => {
setProfileToDelete(profile);
@@ -896,6 +1012,11 @@ export function ProfilesDataTable({
openProxySelectorFor,
proxyOverrides,
handleProxySelection,
profileToRename,
newProfileName,
renameError,
isRenamingSaving,
handleRename,
],
);
@@ -923,7 +1044,7 @@ export function ProfilesDataTable({
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id} className="overflow-visible">
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
@@ -945,10 +1066,10 @@ export function ProfilesDataTable({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="hover:bg-accent/50"
className="overflow-visible hover:bg-accent/50"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<TableCell key={cell.id} className="overflow-visible">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
@@ -970,55 +1091,6 @@ export function ProfilesDataTable({
</TableBody>
</Table>
</ScrollArea>
<Dialog
open={profileToRename !== null}
onOpenChange={(open) => {
if (!open) {
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Profile</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 gap-4 items-center">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
value={newProfileName}
onChange={(e) => {
setNewProfileName(e.target.value);
}}
className="col-span-3"
/>
</div>
{renameError && (
<p className="text-sm text-red-600">{renameError}</p>
)}
</div>
<DialogFooter>
<RippleButton
variant="outline"
onClick={() => {
setProfileToRename(null);
}}
>
Cancel
</RippleButton>
<RippleButton onClick={() => void handleRename()}>
Save
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteConfirmationDialog
isOpen={profileToDelete !== null}
onClose={() => setProfileToDelete(null)}
+67 -19
View File
@@ -1,10 +1,11 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { emit, listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { FiEdit2, FiPlus, FiTrash2, FiWifi } from "react-icons/fi";
import { toast } from "sonner";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -38,6 +39,8 @@ export function ProxyManagementDialog({
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 {
@@ -91,22 +94,46 @@ export function ProxyManagementDialog({
};
}, [isOpen, loadProxyUsage]);
const handleDeleteProxy = useCallback(async (proxy: StoredProxy) => {
if (
!confirm(`Are you sure you want to delete the proxy "${proxy.name}"?`)
) {
return;
}
// 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 handleDeleteProxy = useCallback((proxy: StoredProxy) => {
// Open in-app confirmation dialog
setProxyToDelete(proxy);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!proxyToDelete) return;
setIsDeleting(true);
try {
await invoke("delete_stored_proxy", { proxyId: proxy.id });
setStoredProxies((prev) => prev.filter((p) => p.id !== proxy.id));
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) {
console.error("Failed to delete proxy:", error);
toast.error("Failed to delete proxy");
} finally {
setIsDeleting(false);
setProxyToDelete(null);
}
}, []);
}, [proxyToDelete]);
const handleCreateProxy = useCallback(() => {
setEditingProxy(null);
@@ -133,6 +160,7 @@ export function ProxyManagementDialog({
});
setShowProxyForm(false);
setEditingProxy(null);
void emit("stored-proxies-changed");
}, []);
const handleProxyFormClose = useCallback(() => {
@@ -241,17 +269,28 @@ export function ProxyManagementDialog({
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteProxy(proxy)}
className="text-destructive hover:text-destructive"
>
<FiTrash2 className="w-4 h-4" />
</Button>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteProxy(proxy)}
className="text-destructive hover:text-destructive"
disabled={(proxyUsage[proxy.id] ?? 0) > 0}
>
<FiTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>Delete proxy</p>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by {proxyUsage[proxy.id]}{" "}
profile
{proxyUsage[proxy.id] > 1 ? "s" : ""}
</p>
) : (
<p>Delete proxy</p>
)}
</TooltipContent>
</Tooltip>
</div>
@@ -274,6 +313,15 @@ export function ProxyManagementDialog({
onSave={handleProxySaved}
editingProxy={editingProxy}
/>
<DeleteConfirmationDialog
isOpen={proxyToDelete !== null}
onClose={() => setProxyToDelete(null)}
onConfirm={handleConfirmDelete}
title="Delete Proxy"
description={`This action cannot be undone. This will permanently delete the proxy "${proxyToDelete?.name ?? ""}".`}
confirmButtonText="Delete"
isLoading={isDeleting}
/>
</>
);
}
+3 -91
View File
@@ -1,7 +1,6 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useState } from "react";
import { BsCamera, BsMic } from "react-icons/bs";
@@ -25,13 +24,7 @@ import {
} from "@/components/ui/select";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import {
dismissToast,
showErrorToast,
showSuccessToast,
showUnifiedVersionUpdateToast,
} from "@/lib/toast-utils";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
@@ -45,14 +38,7 @@ interface PermissionInfo {
description: string;
}
interface VersionUpdateProgress {
current_browser: string;
total_browsers: number;
completed_browsers: number;
new_versions_found: number;
browser_new_versions: number;
status: string; // "updating", "completed", "error"
}
// Version update progress toasts are handled globally via useVersionUpdater
interface SettingsDialogProps {
isOpen: boolean;
@@ -265,83 +251,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
checkDefaultBrowserStatus().catch(console.error);
}, 500); // Check every 500ms
// Listen for version update progress events
let unlistenFn: (() => void) | null = null;
const setupVersionUpdateListener = async () => {
try {
unlistenFn = await listen<VersionUpdateProgress>(
"version-update-progress",
(event) => {
const progress = event.payload;
if (progress.status === "updating") {
// Show unified progress toast
const currentBrowserName = progress.current_browser
? getBrowserDisplayName(progress.current_browser)
: undefined;
showUnifiedVersionUpdateToast(
"Checking for browser updates...",
{
description: currentBrowserName
? `Fetching ${currentBrowserName} release information...`
: "Initializing version check...",
progress: {
current: progress.completed_browsers,
total: progress.total_browsers,
found: progress.new_versions_found,
current_browser: currentBrowserName,
},
},
);
} else if (progress.status === "completed") {
dismissToast("unified-version-update");
if (progress.new_versions_found > 0) {
showSuccessToast("Browser versions updated successfully", {
duration: 5000,
description:
"Auto-downloads will start shortly for available updates.",
});
} else {
showSuccessToast("No new browser versions found", {
duration: 3000,
description: "All browser versions are up to date",
});
}
} else if (progress.status === "error") {
dismissToast("unified-version-update");
showErrorToast("Failed to update browser versions", {
duration: 6000,
description: "Check your internet connection and try again",
});
}
},
);
} catch (error) {
console.error(
"Failed to setup version update progress listener:",
error,
);
}
};
setupVersionUpdateListener();
// Cleanup interval and listener on component unmount or dialog close
// Cleanup interval on component unmount or dialog close
return () => {
clearInterval(intervalId);
if (unlistenFn) {
try {
unlistenFn();
} catch (error) {
console.error(
"Failed to cleanup version update progress listener:",
error,
);
}
}
};
}
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
@@ -83,7 +83,7 @@ export function SharedCamoufoxConfigForm({
forceAdvanced = false,
}: SharedCamoufoxConfigFormProps) {
const [activeTab, setActiveTab] = useState(
forceAdvanced ? "advanced" : "normal",
forceAdvanced ? "manual" : "automatic",
);
const [fingerprintConfig, setFingerprintConfig] =
useState<CamoufoxFingerprintConfig>({});
@@ -817,14 +817,13 @@ export function SharedCamoufoxConfigForm({
// Advanced mode only (for editing)
renderAdvancedForm()
) : (
// Normal/Advanced tabs for creation
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="normal">Normal</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
<TabsTrigger value="automatic">Automatic</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="normal" className="space-y-6">
<TabsContent value="automatic" className="space-y-6">
{/* Automatic Location Configuration */}
<div className="mt-4 space-y-3">
<div className="flex items-center space-x-2">
@@ -908,7 +907,7 @@ export function SharedCamoufoxConfigForm({
</div>
</TabsContent>
<TabsContent value="advanced" className="space-y-6">
<TabsContent value="manual" className="space-y-6">
{renderAdvancedForm()}
</TabsContent>
</Tabs>