mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-07 23:43:57 +02:00
feat: synchronizer
This commit is contained in:
@@ -240,152 +240,150 @@ export function GroupManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="overflow-y-auto flex-1">
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Groups</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Groups</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No groups created yet. Create your first group using the
|
||||
button above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Profiles</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
group,
|
||||
groupSyncStatus[group.id],
|
||||
groupSyncErrors[group.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate ? "animate-pulse" : ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{group.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{group.count}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{/* Groups list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No groups created yet. Create your first group using the button
|
||||
above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Profiles</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
group,
|
||||
groupSyncStatus[group.id],
|
||||
groupSyncErrors[group.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={group.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleSync(group)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[group.id] ||
|
||||
groupInUse[group.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate ? "animate-pulse" : ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{groupInUse[group.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this group
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{group.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{group.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{group.count}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={group.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleSync(group)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[group.id] ||
|
||||
groupInUse[group.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{groupInUse[group.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this group
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{group.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
|
||||
+178
-15
@@ -1,3 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
@@ -24,6 +25,148 @@ import { Input } from "./ui/input";
|
||||
import { ProBadge } from "./ui/pro-badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
const CLICK_THRESHOLD = 5;
|
||||
const CLICK_WINDOW_MS = 2000;
|
||||
const GRAVITY = 2200;
|
||||
const BOUNCE_DAMPING = 0.6;
|
||||
const INITIAL_HORIZONTAL_SPEED = 350;
|
||||
const SPIN_SPEED = 720;
|
||||
const MIN_BOUNCE_VELOCITY = 60;
|
||||
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
|
||||
|
||||
function useLogoEasterEgg() {
|
||||
const clickTimestamps = useRef<number[]>([]);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [wobbleKey, setWobbleKey] = useState(0);
|
||||
const [isFalling, setIsFalling] = useState(false);
|
||||
const [isHidden, setIsHidden] = useState(() => {
|
||||
try {
|
||||
return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const logoRef = useRef<HTMLButtonElement>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
|
||||
const triggerFall = useCallback(() => {
|
||||
const el = logoRef.current;
|
||||
if (!el || isFalling) return;
|
||||
|
||||
setIsFalling(true);
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const startX = rect.left;
|
||||
const startY = rect.top;
|
||||
const floorY = window.innerHeight;
|
||||
const leftWall = 0;
|
||||
const rightWall = window.innerWidth;
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "fixed";
|
||||
clone.style.left = `${startX}px`;
|
||||
clone.style.top = `${startY}px`;
|
||||
clone.style.zIndex = "9999";
|
||||
clone.style.pointerEvents = "none";
|
||||
clone.style.margin = "0";
|
||||
document.body.appendChild(clone);
|
||||
|
||||
el.style.visibility = "hidden";
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vy = -500;
|
||||
let vx = -INITIAL_HORIZONTAL_SPEED;
|
||||
let rotation = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const animate = (time: number) => {
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
||||
lastTime = time;
|
||||
|
||||
vy += GRAVITY * dt;
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
|
||||
|
||||
// Floor bounce
|
||||
const currentBottom = startY + y + rect.height;
|
||||
if (currentBottom >= floorY && vy > 0) {
|
||||
y = floorY - startY - rect.height;
|
||||
if (Math.abs(vy) > MIN_BOUNCE_VELOCITY) {
|
||||
vy = -Math.abs(vy) * BOUNCE_DAMPING;
|
||||
} else {
|
||||
vy = -MIN_BOUNCE_VELOCITY * 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Left wall bounce only — right wall lets it fly off screen
|
||||
const currentLeft = startX + x;
|
||||
if (currentLeft <= leftWall && vx < 0) {
|
||||
x = leftWall - startX;
|
||||
vx = Math.abs(vx) * 1.1;
|
||||
}
|
||||
|
||||
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
|
||||
|
||||
// Only end when fully off-screen vertically (bounced out the top or flew off bottom somehow)
|
||||
const currentTop = startY + y;
|
||||
const offScreenRight = startX + x > rightWall + 50;
|
||||
const offScreenBottom = currentTop > floorY + 100;
|
||||
const offScreenTop = currentTop + rect.height < -200;
|
||||
|
||||
if (offScreenRight || offScreenBottom || offScreenTop) {
|
||||
clone.remove();
|
||||
try {
|
||||
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setIsHidden(true);
|
||||
setIsFalling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
}, [isFalling]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isFalling || isHidden) return;
|
||||
|
||||
const now = Date.now();
|
||||
clickTimestamps.current = clickTimestamps.current.filter(
|
||||
(t) => now - t < CLICK_WINDOW_MS,
|
||||
);
|
||||
clickTimestamps.current.push(now);
|
||||
|
||||
if (clickTimestamps.current.length >= CLICK_THRESHOLD) {
|
||||
clickTimestamps.current = [];
|
||||
triggerFall();
|
||||
} else {
|
||||
setWobbleKey((k) => k + 1);
|
||||
}
|
||||
}, [isFalling, isHidden, triggerFall]);
|
||||
|
||||
return {
|
||||
logoRef,
|
||||
isPressed,
|
||||
setIsPressed,
|
||||
wobbleKey,
|
||||
isFalling,
|
||||
isHidden,
|
||||
handleClick,
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onSettingsDialogOpen: (open: boolean) => void;
|
||||
onProxyManagementDialogOpen: (open: boolean) => void;
|
||||
@@ -52,24 +195,44 @@ const HomeHeader = ({
|
||||
crossOsUnlocked = false,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const handleLogoClick = () => {
|
||||
// Trigger the same URL handling logic as if the URL came from the system
|
||||
const event = new CustomEvent("url-open-request", {
|
||||
detail: "https://donutbrowser.com",
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
const {
|
||||
logoRef,
|
||||
isPressed,
|
||||
setIsPressed,
|
||||
wobbleKey,
|
||||
isFalling,
|
||||
isHidden,
|
||||
handleClick,
|
||||
} = useLogoEasterEgg();
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div className="flex gap-3 items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 cursor-pointer"
|
||||
title="Open donutbrowser.com"
|
||||
onClick={handleLogoClick}
|
||||
>
|
||||
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" />
|
||||
</button>
|
||||
{!isHidden ? (
|
||||
<button
|
||||
ref={logoRef}
|
||||
type="button"
|
||||
className="p-1 cursor-pointer select-none"
|
||||
onClick={handleClick}
|
||||
onPointerDown={() => setIsPressed(true)}
|
||||
onPointerUp={() => setIsPressed(false)}
|
||||
onPointerLeave={() => setIsPressed(false)}
|
||||
>
|
||||
<Logo
|
||||
key={wobbleKey}
|
||||
className={cn(
|
||||
"w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110",
|
||||
isPressed && "scale-90",
|
||||
!isFalling &&
|
||||
!isPressed &&
|
||||
wobbleKey > 0 &&
|
||||
"animate-[wiggle_0.3s_ease-in-out]",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="p-1 w-10 h-10" />
|
||||
)}
|
||||
<CardTitle>Donut</CardTitle>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
LuLock,
|
||||
LuPuzzle,
|
||||
LuTrash2,
|
||||
LuTriangleAlert,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
@@ -83,6 +84,7 @@ import type {
|
||||
LocationItem,
|
||||
ProxyCheckResult,
|
||||
StoredProxy,
|
||||
SyncSessionInfo,
|
||||
TrafficSnapshot,
|
||||
VpnConfig,
|
||||
} from "@/types";
|
||||
@@ -204,6 +206,16 @@ type TableMeta = {
|
||||
// Team locks
|
||||
isProfileLockedByAnother: (profileId: string) => boolean;
|
||||
getProfileLockEmail: (profileId: string) => string | undefined;
|
||||
|
||||
// Synchronizer
|
||||
getProfileSyncInfo: (profileId: string) =>
|
||||
| {
|
||||
session: SyncSessionInfo;
|
||||
isLeader: boolean;
|
||||
failedAtUrl: string | null;
|
||||
}
|
||||
| undefined;
|
||||
onLaunchWithSync: (profile: BrowserProfile) => void;
|
||||
};
|
||||
|
||||
type SyncStatusDot = {
|
||||
@@ -242,7 +254,7 @@ function getProfileSyncStatusDot(
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Waiting to sync",
|
||||
tooltip: "Close the profile to sync",
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
@@ -801,6 +813,14 @@ interface ProfilesDataTableProps {
|
||||
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
syncUnlocked?: boolean;
|
||||
getProfileSyncInfo?: (profileId: string) =>
|
||||
| {
|
||||
session: SyncSessionInfo;
|
||||
isLeader: boolean;
|
||||
failedAtUrl: string | null;
|
||||
}
|
||||
| undefined;
|
||||
onLaunchWithSync?: (profile: BrowserProfile) => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -828,6 +848,8 @@ export function ProfilesDataTable({
|
||||
onToggleProfileSync,
|
||||
crossOsUnlocked = false,
|
||||
syncUnlocked = false,
|
||||
getProfileSyncInfo,
|
||||
onLaunchWithSync,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
@@ -951,8 +973,7 @@ export function ProfilesDataTable({
|
||||
// Country proxy creation state (for inline proxy creation in dropdown)
|
||||
const [countries, setCountries] = React.useState<LocationItem[]>([]);
|
||||
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
|
||||
const hasCloudProxy = storedProxies.some((p) => p.is_cloud_managed);
|
||||
const canCreateLocationProxy = hasCloudProxy || crossOsUnlocked;
|
||||
const canCreateLocationProxy = false;
|
||||
|
||||
const loadCountries = React.useCallback(async () => {
|
||||
if (countriesLoaded || !canCreateLocationProxy) return;
|
||||
@@ -963,7 +984,7 @@ export function ProfilesDataTable({
|
||||
} catch (e) {
|
||||
console.error("Failed to load countries:", e);
|
||||
}
|
||||
}, [countriesLoaded, canCreateLocationProxy]);
|
||||
}, [countriesLoaded]);
|
||||
|
||||
// Load cached check results for proxies
|
||||
React.useEffect(() => {
|
||||
@@ -1528,6 +1549,10 @@ export function ProfilesDataTable({
|
||||
isProfileLockedByAnother: isProfileLocked,
|
||||
getProfileLockEmail: (profileId: string) =>
|
||||
getLockInfo(profileId)?.lockedByEmail,
|
||||
|
||||
// Synchronizer
|
||||
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
|
||||
onLaunchWithSync: onLaunchWithSync ?? (() => {}),
|
||||
}),
|
||||
[
|
||||
t,
|
||||
@@ -1577,11 +1602,12 @@ export function ProfilesDataTable({
|
||||
crossOsUnlocked,
|
||||
syncUnlocked,
|
||||
countries,
|
||||
canCreateLocationProxy,
|
||||
loadCountries,
|
||||
handleCreateCountryProxy,
|
||||
isProfileLocked,
|
||||
getLockInfo,
|
||||
getProfileSyncInfo,
|
||||
onLaunchWithSync,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1806,23 +1832,81 @@ export function ProfilesDataTable({
|
||||
}
|
||||
};
|
||||
|
||||
const syncInfo = meta.getProfileSyncInfo(profile.id);
|
||||
const isLeader = syncInfo?.isLeader === true;
|
||||
const isFollower = syncInfo?.isLeader === false;
|
||||
const isDesynced = isFollower && syncInfo?.failedAtUrl != null;
|
||||
const stopTooltip = isLeader
|
||||
? meta.t("profiles.synchronizer.stopLeader")
|
||||
: isFollower
|
||||
? meta.t("profiles.synchronizer.stopFollower", {
|
||||
leaderName: syncInfo?.session.leader_profile_name ?? "",
|
||||
})
|
||||
: tooltipContent;
|
||||
|
||||
const handleStop = async () => {
|
||||
if (isLeader && syncInfo) {
|
||||
// Stop leader: invoke stop_sync_session which kills leader + all followers
|
||||
try {
|
||||
await invoke("stop_sync_session", {
|
||||
sessionId: syncInfo.session.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to stop sync session:", error);
|
||||
}
|
||||
} else if (isFollower && syncInfo) {
|
||||
// Stop follower: remove from session
|
||||
try {
|
||||
await invoke("remove_sync_follower", {
|
||||
sessionId: syncInfo.session.id,
|
||||
followerProfileId: profile.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to remove sync follower:", error);
|
||||
}
|
||||
} else {
|
||||
await handleProfileStop(profile);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonVariant = isRunning
|
||||
? isFollower
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
{isDesynced && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<LuTriangleAlert className="w-4 h-4 text-warning" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{meta.t("profiles.synchronizer.desyncedTooltip", {
|
||||
url: syncInfo?.failedAtUrl ?? "",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<RippleButton
|
||||
variant={isRunning ? "destructive" : "default"}
|
||||
variant={buttonVariant}
|
||||
size="sm"
|
||||
disabled={!canLaunch || isLaunching || isStopping}
|
||||
className={cn(
|
||||
"min-w-[70px] h-7",
|
||||
!canLaunch && "opacity-50 cursor-not-allowed",
|
||||
canLaunch && "cursor-pointer",
|
||||
isFollower && "border-accent",
|
||||
)}
|
||||
onClick={() =>
|
||||
isRunning
|
||||
? handleProfileStop(profile)
|
||||
? void handleStop()
|
||||
: handleProfileLaunch(profile)
|
||||
}
|
||||
>
|
||||
@@ -1838,8 +1922,10 @@ export function ProfilesDataTable({
|
||||
</RippleButton>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{tooltipContent && (
|
||||
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||
{(stopTooltip || tooltipContent) && (
|
||||
<TooltipContent>
|
||||
{isRunning ? stopTooltip : tooltipContent}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -2188,28 +2274,35 @@ export function ProfilesDataTable({
|
||||
/>
|
||||
None
|
||||
</CommandItem>
|
||||
{meta.storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() =>
|
||||
void meta.handleProxySelection(
|
||||
profile.id,
|
||||
proxy.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveProxyId === proxy.id && !effectiveVpn
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
{meta.storedProxies
|
||||
.filter(
|
||||
(proxy: StoredProxy) =>
|
||||
!proxy.is_cloud_managed &&
|
||||
!proxy.is_cloud_derived,
|
||||
)
|
||||
.map((proxy: StoredProxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() =>
|
||||
void meta.handleProxySelection(
|
||||
profile.id,
|
||||
proxy.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveProxyId === proxy.id &&
|
||||
!effectiveVpn
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{meta.vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
@@ -2519,6 +2612,7 @@ export function ProfilesDataTable({
|
||||
onAssignExtensionGroup={onAssignExtensionGroup}
|
||||
onOpenBypassRules={(profile) => setBypassRulesProfile(profile)}
|
||||
onCloneProfile={onCloneProfile}
|
||||
onLaunchWithSync={onLaunchWithSync}
|
||||
onDeleteProfile={(profile) => {
|
||||
setProfileForInfoDialog(null);
|
||||
setProfileToDelete(profile);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
LuSettings,
|
||||
LuShieldCheck,
|
||||
LuTrash2,
|
||||
LuUsers,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -65,6 +66,7 @@ interface ProfileInfoDialogProps {
|
||||
onOpenBypassRules?: (profile: BrowserProfile) => void;
|
||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||
onDeleteProfile?: (profile: BrowserProfile) => void;
|
||||
onLaunchWithSync?: (profile: BrowserProfile) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
isRunning?: boolean;
|
||||
isDisabled?: boolean;
|
||||
@@ -110,6 +112,7 @@ export function ProfileInfoDialog({
|
||||
onOpenBypassRules,
|
||||
onCloneProfile,
|
||||
onDeleteProfile,
|
||||
onLaunchWithSync,
|
||||
crossOsUnlocked = false,
|
||||
isRunning = false,
|
||||
isDisabled = false,
|
||||
@@ -251,6 +254,14 @@ export function ProfileInfoDialog({
|
||||
runningBadge: isRunning,
|
||||
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
|
||||
},
|
||||
{
|
||||
icon: <LuUsers className="w-4 h-4" />,
|
||||
label: t("profiles.synchronizer.launchWithSync"),
|
||||
onClick: () => handleAction(() => onLaunchWithSync?.(profile)),
|
||||
disabled: isDisabled || isRunning || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
|
||||
},
|
||||
{
|
||||
icon: <LuCopy className="w-4 h-4" />,
|
||||
label: t("profiles.actions.copyCookiesToProfile"),
|
||||
|
||||
@@ -186,9 +186,7 @@ export function ProxyAssignmentDialog({
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedId,
|
||||
);
|
||||
return proxy
|
||||
? `${proxy.name}${proxy.is_cloud_managed ? " (Included)" : ""}`
|
||||
: "None";
|
||||
return proxy ? proxy.name : "None";
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -216,28 +214,32 @@ export function ProxyAssignmentDialog({
|
||||
/>
|
||||
None
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() => {
|
||||
handleValueChange(proxy.id);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectionType === "proxy" &&
|
||||
selectedId === proxy.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
{proxy.is_cloud_managed ? " (Included)" : ""}
|
||||
</CommandItem>
|
||||
))}
|
||||
{storedProxies
|
||||
.filter(
|
||||
(proxy) =>
|
||||
!proxy.is_cloud_managed && !proxy.is_cloud_derived,
|
||||
)
|
||||
.map((proxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() => {
|
||||
handleValueChange(proxy.id);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectionType === "proxy" &&
|
||||
selectedId === proxy.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
|
||||
@@ -50,7 +50,9 @@ export function ProxyCheckButton({
|
||||
try {
|
||||
const result = await invoke<ProxyCheckResult>("check_proxy_validity", {
|
||||
proxyId: proxy.id,
|
||||
proxySettings: proxy.proxy_settings,
|
||||
proxySettings: proxy.dynamic_proxy_url
|
||||
? undefined
|
||||
: proxy.proxy_settings,
|
||||
});
|
||||
setLocalResult(result);
|
||||
onCheckComplete?.(result);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -20,10 +21,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { ProxySettings, StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProxyFormData {
|
||||
interface RegularFormData {
|
||||
name: string;
|
||||
proxy_type: string;
|
||||
host: string;
|
||||
@@ -32,6 +34,14 @@ interface ProxyFormData {
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface DynamicFormData {
|
||||
name: string;
|
||||
url: string;
|
||||
format: string;
|
||||
}
|
||||
|
||||
type ProxyMode = "regular" | "dynamic";
|
||||
|
||||
interface ProxyFormDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -43,8 +53,11 @@ export function ProxyFormDialog({
|
||||
onClose,
|
||||
editingProxy,
|
||||
}: ProxyFormDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState<ProxyFormData>({
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [mode, setMode] = useState<ProxyMode>("regular");
|
||||
const [regularForm, setRegularForm] = useState<RegularFormData>({
|
||||
name: "",
|
||||
proxy_type: "http",
|
||||
host: "",
|
||||
@@ -52,9 +65,14 @@ export function ProxyFormDialog({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [dynamicForm, setDynamicForm] = useState<DynamicFormData>({
|
||||
name: "",
|
||||
url: "",
|
||||
format: "json",
|
||||
});
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({
|
||||
setRegularForm({
|
||||
name: "",
|
||||
proxy_type: "http",
|
||||
host: "",
|
||||
@@ -62,62 +80,134 @@ export function ProxyFormDialog({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
setDynamicForm({
|
||||
name: "",
|
||||
url: "",
|
||||
format: "json",
|
||||
});
|
||||
setMode("regular");
|
||||
}, []);
|
||||
|
||||
// Load editing proxy data when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (editingProxy) {
|
||||
setFormData({
|
||||
name: editingProxy.name,
|
||||
proxy_type: editingProxy.proxy_settings.proxy_type,
|
||||
host: editingProxy.proxy_settings.host,
|
||||
port: editingProxy.proxy_settings.port,
|
||||
username: editingProxy.proxy_settings.username || "",
|
||||
password: editingProxy.proxy_settings.password || "",
|
||||
});
|
||||
if (editingProxy.dynamic_proxy_url) {
|
||||
setMode("dynamic");
|
||||
setDynamicForm({
|
||||
name: editingProxy.name,
|
||||
url: editingProxy.dynamic_proxy_url,
|
||||
format: editingProxy.dynamic_proxy_format || "json",
|
||||
});
|
||||
} else {
|
||||
setMode("regular");
|
||||
setRegularForm({
|
||||
name: editingProxy.name,
|
||||
proxy_type: editingProxy.proxy_settings.proxy_type,
|
||||
host: editingProxy.proxy_settings.host,
|
||||
port: editingProxy.proxy_settings.port,
|
||||
username: editingProxy.proxy_settings.username || "",
|
||||
password: editingProxy.proxy_settings.password || "",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingProxy, resetForm]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error("Proxy name is required");
|
||||
const handleTestDynamic = useCallback(async () => {
|
||||
if (!dynamicForm.url.trim()) {
|
||||
toast.error(t("proxies.dynamic.urlRequired"));
|
||||
return;
|
||||
}
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const settings = await invoke<ProxySettings>("fetch_dynamic_proxy", {
|
||||
url: dynamicForm.url.trim(),
|
||||
format: dynamicForm.format,
|
||||
});
|
||||
toast.success(
|
||||
t("proxies.dynamic.testSuccess", {
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(t("proxies.dynamic.testFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [dynamicForm, t]);
|
||||
|
||||
if (!formData.host.trim() || !formData.port) {
|
||||
toast.error("Host and port are required");
|
||||
return;
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (mode === "regular") {
|
||||
if (!regularForm.name.trim()) {
|
||||
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
|
||||
return;
|
||||
}
|
||||
if (!regularForm.host.trim() || !regularForm.port) {
|
||||
toast.error(
|
||||
t("proxies.form.hostPortRequired", "Host and port are required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!dynamicForm.name.trim()) {
|
||||
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
|
||||
return;
|
||||
}
|
||||
if (!dynamicForm.url.trim()) {
|
||||
toast.error(t("proxies.dynamic.urlRequired"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const proxySettings = {
|
||||
proxy_type: formData.proxy_type,
|
||||
host: formData.host.trim(),
|
||||
port: formData.port,
|
||||
username: formData.username.trim() || undefined,
|
||||
password: formData.password.trim() || undefined,
|
||||
};
|
||||
|
||||
if (editingProxy) {
|
||||
// Update existing proxy
|
||||
await invoke("update_stored_proxy", {
|
||||
proxyId: editingProxy.id,
|
||||
name: formData.name.trim(),
|
||||
proxySettings,
|
||||
});
|
||||
toast.success("Proxy updated successfully");
|
||||
if (mode === "dynamic") {
|
||||
await invoke("update_stored_proxy", {
|
||||
proxyId: editingProxy.id,
|
||||
name: dynamicForm.name.trim(),
|
||||
dynamicProxyUrl: dynamicForm.url.trim(),
|
||||
dynamicProxyFormat: dynamicForm.format,
|
||||
});
|
||||
} else {
|
||||
await invoke("update_stored_proxy", {
|
||||
proxyId: editingProxy.id,
|
||||
name: regularForm.name.trim(),
|
||||
proxySettings: {
|
||||
proxy_type: regularForm.proxy_type,
|
||||
host: regularForm.host.trim(),
|
||||
port: regularForm.port,
|
||||
username: regularForm.username.trim() || undefined,
|
||||
password: regularForm.password.trim() || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
toast.success(t("toasts.success.proxyUpdated"));
|
||||
} else {
|
||||
// Create new proxy
|
||||
await invoke("create_stored_proxy", {
|
||||
name: formData.name.trim(),
|
||||
proxySettings,
|
||||
});
|
||||
toast.success("Proxy created successfully");
|
||||
if (mode === "dynamic") {
|
||||
await invoke("create_stored_proxy", {
|
||||
name: dynamicForm.name.trim(),
|
||||
dynamicProxyUrl: dynamicForm.url.trim(),
|
||||
dynamicProxyFormat: dynamicForm.format,
|
||||
});
|
||||
} else {
|
||||
await invoke("create_stored_proxy", {
|
||||
name: regularForm.name.trim(),
|
||||
proxySettings: {
|
||||
proxy_type: regularForm.proxy_type,
|
||||
host: regularForm.host.trim(),
|
||||
port: regularForm.port,
|
||||
username: regularForm.username.trim() || undefined,
|
||||
password: regularForm.password.trim() || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
toast.success(t("toasts.success.proxyCreated"));
|
||||
}
|
||||
|
||||
onClose();
|
||||
@@ -129,7 +219,7 @@ export function ProxyFormDialog({
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, editingProxy, onClose]);
|
||||
}, [mode, regularForm, dynamicForm, editingProxy, onClose, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
@@ -137,125 +227,227 @@ export function ProxyFormDialog({
|
||||
}
|
||||
}, [isSubmitting, onClose]);
|
||||
|
||||
const isFormValid =
|
||||
formData.name.trim() &&
|
||||
formData.host.trim() &&
|
||||
formData.port > 0 &&
|
||||
formData.port <= 65535;
|
||||
const isRegularValid =
|
||||
regularForm.name.trim() &&
|
||||
regularForm.host.trim() &&
|
||||
regularForm.port > 0 &&
|
||||
regularForm.port <= 65535;
|
||||
|
||||
const isDynamicValid = dynamicForm.name.trim() && dynamicForm.url.trim();
|
||||
|
||||
const isFormValid = mode === "regular" ? isRegularValid : isDynamicValid;
|
||||
|
||||
const isEditingDynamic = editingProxy?.dynamic_proxy_url != null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProxy ? "Edit Proxy" : "Create New Proxy"}
|
||||
{editingProxy ? t("proxies.edit") : t("proxies.add")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-name">Proxy Name</Label>
|
||||
<Input
|
||||
id="proxy-name"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder="e.g. Office Proxy, Home VPN, etc."
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{!editingProxy && (
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as ProxyMode)}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="regular" className="flex-1">
|
||||
{t("proxies.tabs.regular")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="dynamic" className="flex-1">
|
||||
{t("proxies.tabs.dynamic")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Proxy Type</Label>
|
||||
<Select
|
||||
value={formData.proxy_type}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, proxy_type: value })
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select proxy type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["http", "https", "socks4", "socks5"].map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{editingProxy && isEditingDynamic && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxies.dynamic.description")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-host">Host</Label>
|
||||
<Input
|
||||
id="proxy-host"
|
||||
value={formData.host}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, host: e.target.value })
|
||||
}
|
||||
placeholder="e.g. 127.0.0.1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{mode === "regular" ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
|
||||
<Input
|
||||
id="proxy-name"
|
||||
value={regularForm.name}
|
||||
onChange={(e) =>
|
||||
setRegularForm({ ...regularForm, name: e.target.value })
|
||||
}
|
||||
placeholder="e.g. Office Proxy, Home VPN, etc."
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-port">Port</Label>
|
||||
<Input
|
||||
id="proxy-port"
|
||||
type="number"
|
||||
value={formData.port}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
port: parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
placeholder="e.g. 8080"
|
||||
min="1"
|
||||
max="65535"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("proxies.form.type")}</Label>
|
||||
<Select
|
||||
value={regularForm.proxy_type}
|
||||
onValueChange={(value) =>
|
||||
setRegularForm({ ...regularForm, proxy_type: value })
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select proxy type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["http", "https", "socks4", "socks5"].map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-username">Username (optional)</Label>
|
||||
<Input
|
||||
id="proxy-username"
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
username: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Proxy username"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
|
||||
<Input
|
||||
id="proxy-host"
|
||||
value={regularForm.host}
|
||||
onChange={(e) =>
|
||||
setRegularForm({ ...regularForm, host: e.target.value })
|
||||
}
|
||||
placeholder={t("proxies.form.hostPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-password">Password (optional)</Label>
|
||||
<Input
|
||||
id="proxy-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
password: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Proxy password"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
|
||||
<Input
|
||||
id="proxy-port"
|
||||
type="number"
|
||||
value={regularForm.port}
|
||||
onChange={(e) =>
|
||||
setRegularForm({
|
||||
...regularForm,
|
||||
port: parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
placeholder={t("proxies.form.portPlaceholder")}
|
||||
min="1"
|
||||
max="65535"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-username">
|
||||
{t("proxies.form.username")} (
|
||||
{t("proxies.form.usernamePlaceholder")})
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-username"
|
||||
value={regularForm.username}
|
||||
onChange={(e) =>
|
||||
setRegularForm({
|
||||
...regularForm,
|
||||
username: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("proxies.form.usernamePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-password">
|
||||
{t("proxies.form.password")} (
|
||||
{t("proxies.form.passwordPlaceholder")})
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-password"
|
||||
type="password"
|
||||
value={regularForm.password}
|
||||
onChange={(e) =>
|
||||
setRegularForm({
|
||||
...regularForm,
|
||||
password: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("proxies.form.passwordPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dynamic-name">{t("proxies.form.name")}</Label>
|
||||
<Input
|
||||
id="dynamic-name"
|
||||
value={dynamicForm.name}
|
||||
onChange={(e) =>
|
||||
setDynamicForm({ ...dynamicForm, name: e.target.value })
|
||||
}
|
||||
placeholder="e.g. My Tunnel"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dynamic-url">{t("proxies.dynamic.url")}</Label>
|
||||
<Input
|
||||
id="dynamic-url"
|
||||
value={dynamicForm.url}
|
||||
onChange={(e) =>
|
||||
setDynamicForm({ ...dynamicForm, url: e.target.value })
|
||||
}
|
||||
placeholder={t("proxies.dynamic.urlPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("proxies.dynamic.format")}</Label>
|
||||
<Select
|
||||
value={dynamicForm.format}
|
||||
onValueChange={(value) =>
|
||||
setDynamicForm({ ...dynamicForm, format: value })
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">
|
||||
{t("proxies.dynamic.formatJson")}
|
||||
</SelectItem>
|
||||
<SelectItem value="text">
|
||||
{t("proxies.dynamic.formatText")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dynamicForm.format === "json"
|
||||
? t("proxies.dynamic.formatJsonHint")
|
||||
: t("proxies.dynamic.formatTextHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestDynamic}
|
||||
disabled={isSubmitting || isTesting || !dynamicForm.url.trim()}
|
||||
>
|
||||
{isTesting
|
||||
? t("proxies.dynamic.testing")
|
||||
: t("proxies.dynamic.testUrl")}
|
||||
</RippleButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -264,14 +456,14 @@ export function ProxyFormDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t("common.cancel", "Cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormValid}
|
||||
>
|
||||
{editingProxy ? "Update Proxy" : "Create Proxy"}
|
||||
{editingProxy ? t("proxies.edit") : t("proxies.add")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { GoGlobe, GoPlus } from "react-icons/go";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
@@ -40,8 +40,6 @@ import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
|
||||
import { FlagIcon } from "./flag-icon";
|
||||
import { LocationProxyDialog } from "./location-proxy-dialog";
|
||||
import { ProxyCheckButton } from "./proxy-check-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import { VpnCheckButton } from "./vpn-check-button";
|
||||
@@ -102,7 +100,6 @@ export function ProxyManagementDialog({
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [showLocationDialog, setShowLocationDialog] = useState(false);
|
||||
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
|
||||
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
@@ -142,12 +139,10 @@ export function ProxyManagementDialog({
|
||||
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
|
||||
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
|
||||
|
||||
// Filter out the base cloud-managed proxy (it's an internal indicator, not user-facing)
|
||||
// Keep cloud-derived location proxies
|
||||
// Filter out cloud-managed and cloud-derived proxies (cloud proxies are deprecated)
|
||||
const storedProxies = rawProxies
|
||||
.filter((p) => !p.is_cloud_managed)
|
||||
.filter((p) => !p.is_cloud_managed && !p.is_cloud_derived)
|
||||
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
||||
const hasCloudProxy = rawProxies.some((p) => p.is_cloud_managed);
|
||||
|
||||
// Listen for proxy sync status events
|
||||
useEffect(() => {
|
||||
@@ -412,17 +407,6 @@ export function ProxyManagementDialog({
|
||||
</RippleButton>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{hasCloudProxy && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowLocationDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoGlobe className="w-4 h-4" />
|
||||
Location
|
||||
</RippleButton>
|
||||
)}
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
@@ -462,34 +446,33 @@ export function ProxyManagementDialog({
|
||||
proxySyncStatus[proxy.id],
|
||||
proxySyncErrors[proxy.id],
|
||||
);
|
||||
const isDerived = proxy.is_cloud_derived === true;
|
||||
return (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDerived && proxy.geo_country && (
|
||||
<FlagIcon
|
||||
countryCode={proxy.geo_country}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
{!isDerived && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{proxy.name}
|
||||
{proxy.dynamic_proxy_url && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0"
|
||||
>
|
||||
Dynamic
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -554,24 +537,22 @@ export function ProxyManagementDialog({
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!isDerived && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleEditProxy(proxy)
|
||||
}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleEditProxy(proxy)
|
||||
}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
@@ -830,11 +811,6 @@ export function ProxyManagementDialog({
|
||||
isOpen={showExportDialog}
|
||||
onClose={() => setShowExportDialog(false)}
|
||||
/>
|
||||
<LocationProxyDialog
|
||||
isOpen={showLocationDialog}
|
||||
onClose={() => setShowLocationDialog(false)}
|
||||
/>
|
||||
|
||||
<VpnFormDialog
|
||||
isOpen={showVpnForm}
|
||||
onClose={handleVpnFormClose}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { isCrossOsProfile } from "@/lib/browser-utils";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
SyncSessionInfo,
|
||||
WayfernFingerprintConfig,
|
||||
} from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
function getScreenSize(
|
||||
profile: BrowserProfile,
|
||||
): { w: number; h: number } | null {
|
||||
const fp = profile.wayfern_config?.fingerprint;
|
||||
if (!fp) return null;
|
||||
try {
|
||||
const parsed: WayfernFingerprintConfig = JSON.parse(fp);
|
||||
const w = parsed.screenWidth ?? parsed.windowInnerWidth;
|
||||
const h = parsed.screenHeight ?? parsed.windowInnerHeight;
|
||||
if (w && h) return { w, h };
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SyncFollowerDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
leaderProfile: BrowserProfile | null;
|
||||
allProfiles: BrowserProfile[];
|
||||
runningProfiles: Set<string>;
|
||||
}
|
||||
|
||||
export function SyncFollowerDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
leaderProfile,
|
||||
allProfiles,
|
||||
runningProfiles,
|
||||
}: SyncFollowerDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const eligibleProfiles = allProfiles.filter(
|
||||
(p) =>
|
||||
p.id !== leaderProfile?.id &&
|
||||
p.browser === "wayfern" &&
|
||||
!runningProfiles.has(p.id) &&
|
||||
!isCrossOsProfile(p),
|
||||
);
|
||||
|
||||
const leaderScreenSize = useMemo(
|
||||
() => (leaderProfile ? getScreenSize(leaderProfile) : null),
|
||||
[leaderProfile],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback((id: string, checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(() => {
|
||||
if (!leaderProfile || selectedIds.size === 0) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
const leaderId = leaderProfile.id;
|
||||
setSelectedIds(new Set());
|
||||
onClose();
|
||||
|
||||
invoke<SyncSessionInfo>("start_sync_session", {
|
||||
leaderProfileId: leaderId,
|
||||
followerProfileIds: ids,
|
||||
}).catch((err) => {
|
||||
console.error("Failed to start sync session:", err);
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}, [leaderProfile, selectedIds, onClose]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
setSelectedIds(new Set());
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("profiles.synchronizer.selectFollowers")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("profiles.synchronizer.selectFollowersDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{leaderProfile && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-primary/10 border border-primary/20">
|
||||
<Badge variant="default" className="text-xs">
|
||||
{t("profiles.synchronizer.leader")}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{leaderProfile.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[150px]">
|
||||
<div className="space-y-1 p-2">
|
||||
{eligibleProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("profiles.synchronizer.wayfernOnly")}
|
||||
</p>
|
||||
) : (
|
||||
eligibleProfiles.map((profile) => {
|
||||
const followerSize = getScreenSize(profile);
|
||||
const isFlaky =
|
||||
leaderScreenSize &&
|
||||
followerSize &&
|
||||
(leaderScreenSize.w !== followerSize.w ||
|
||||
leaderScreenSize.h !== followerSize.h);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={profile.id}
|
||||
className="flex items-center gap-3 p-2 rounded-md hover:bg-accent cursor-pointer"
|
||||
onClick={() =>
|
||||
handleToggle(
|
||||
profile.id,
|
||||
!selectedIds.has(profile.id),
|
||||
)
|
||||
}
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(profile.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(profile.id, checked === true)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm truncate flex-1">
|
||||
{profile.name}
|
||||
</span>
|
||||
{isFlaky && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 text-warning border-warning/50 shrink-0"
|
||||
>
|
||||
{t("profiles.synchronizer.flakyBadge")}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[250px]">
|
||||
{t("profiles.synchronizer.flakyTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<RippleButton disabled={selectedIds.size === 0} onClick={handleStart}>
|
||||
{t("profiles.synchronizer.startSession")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user