Compare commits

...

5 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
9 changed files with 165 additions and 194 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.9.3",
"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.3"
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.3"
version = "0.9.4"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.9.3",
"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);
+23 -18
View File
@@ -108,7 +108,7 @@ export function CreateProfileDialog({
const handleTabChange = (value: string) => {
if (value === "regular") {
setSelectedBrowser(null);
setSelectedBrowser("firefox");
} else if (value === "anti-detect") {
setSelectedBrowser("camoufox");
}
@@ -179,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)
@@ -198,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) {
@@ -387,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">
+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">
+131 -80
View File
@@ -25,13 +25,6 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -67,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 {
@@ -107,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);
@@ -265,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("");
@@ -277,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;
@@ -622,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>
);
},
},
@@ -750,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",
@@ -872,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);
@@ -917,6 +1012,11 @@ export function ProfilesDataTable({
openProxySelectorFor,
proxyOverrides,
handleProxySelection,
profileToRename,
newProfileName,
renameError,
isRenamingSaving,
handleRename,
],
);
@@ -944,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}>
@@ -966,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(),
@@ -991,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)}
+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]);