mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 01:37:51 +02:00
2335 lines
69 KiB
TypeScript
2335 lines
69 KiB
TypeScript
"use client";
|
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
|
import {
|
|
LuChevronRight,
|
|
LuClipboard,
|
|
LuClipboardCheck,
|
|
LuCookie,
|
|
LuCopy,
|
|
LuFingerprint,
|
|
LuGlobe,
|
|
LuGroup,
|
|
LuKey,
|
|
LuLink,
|
|
LuLockOpen,
|
|
LuPlus,
|
|
LuPuzzle,
|
|
LuRefreshCw,
|
|
LuSettings,
|
|
LuShield,
|
|
LuShieldCheck,
|
|
LuTrash2,
|
|
LuUpload,
|
|
LuUsers,
|
|
LuX,
|
|
} from "react-icons/lu";
|
|
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
|
import { translateBackendError } from "@/lib/backend-errors";
|
|
import { getProfileIcon } from "@/lib/browser-utils";
|
|
import { formatRelativeTime } from "@/lib/flag-utils";
|
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
|
import { cn } from "@/lib/utils";
|
|
import type {
|
|
BrowserProfile,
|
|
CamoufoxConfig,
|
|
ProfileGroup,
|
|
StoredProxy,
|
|
VpnConfig,
|
|
WayfernConfig,
|
|
} from "@/types";
|
|
|
|
interface ProfileInfoDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
profile: BrowserProfile | null;
|
|
storedProxies: StoredProxy[];
|
|
vpnConfigs: VpnConfig[];
|
|
onOpenTrafficDialog?: (profileId: string) => void;
|
|
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
|
onAssignProfilesToGroup?: (profileIds: string[]) => void;
|
|
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
|
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
|
onOpenCookieManagement?: (profile: BrowserProfile) => void;
|
|
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
|
onOpenBypassRules?: (profile: BrowserProfile) => void;
|
|
onOpenDnsBlocklist?: (profile: BrowserProfile) => void;
|
|
onOpenLaunchHook?: (profile: BrowserProfile) => void;
|
|
onCloneProfile?: (profile: BrowserProfile) => void;
|
|
onDeleteProfile?: (profile: BrowserProfile) => void;
|
|
onLaunchWithSync?: (profile: BrowserProfile) => void;
|
|
onSetPassword?: (profile: BrowserProfile) => void;
|
|
onChangePassword?: (profile: BrowserProfile) => void;
|
|
onRemovePassword?: (profile: BrowserProfile) => void;
|
|
crossOsUnlocked?: boolean;
|
|
isRunning?: boolean;
|
|
isDisabled?: boolean;
|
|
isCrossOs?: boolean;
|
|
syncStatuses: Record<string, { status: string; error?: string }>;
|
|
}
|
|
|
|
function _OSIcon({ os }: { os: string }) {
|
|
switch (os) {
|
|
case "macos":
|
|
return <FaApple className="size-3.5" />;
|
|
case "windows":
|
|
return <FaWindows className="size-3.5" />;
|
|
case "linux":
|
|
return <FaLinux className="size-3.5" />;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function InfoCard({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="rounded-md bg-muted/50 border px-3 py-2.5">
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<p className="text-sm mt-0.5 truncate">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
if (bytes < 1024 * 1024 * 1024)
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
}
|
|
|
|
/**
|
|
* Shows the total bytes routed through Donut's local proxy worker for this
|
|
* profile. Only counts traffic flowing through the donut-proxy binary — not
|
|
* the browser's full network usage, hence the "Local" qualifier.
|
|
*/
|
|
function LocalDataTransferCard({
|
|
profileId,
|
|
t,
|
|
}: {
|
|
profileId: string;
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
}) {
|
|
type Snapshot = {
|
|
total_bytes_sent: number;
|
|
total_bytes_received: number;
|
|
};
|
|
const [value, setValue] = React.useState<string>("—");
|
|
|
|
React.useEffect(() => {
|
|
let mounted = true;
|
|
const fetchSnapshot = async () => {
|
|
try {
|
|
const snap = await invoke<Snapshot | null>(
|
|
"get_profile_traffic_snapshot",
|
|
{ profileId },
|
|
);
|
|
if (!mounted) return;
|
|
if (!snap) {
|
|
setValue("0 B");
|
|
return;
|
|
}
|
|
setValue(
|
|
formatBytes(snap.total_bytes_sent + snap.total_bytes_received),
|
|
);
|
|
} catch {
|
|
if (mounted) setValue("—");
|
|
}
|
|
};
|
|
void fetchSnapshot();
|
|
const interval = window.setInterval(fetchSnapshot, 5000);
|
|
return () => {
|
|
mounted = false;
|
|
window.clearInterval(interval);
|
|
};
|
|
}, [profileId]);
|
|
|
|
return (
|
|
<InfoCard label={t("profileInfo.fields.localDataTransfer")} value={value} />
|
|
);
|
|
}
|
|
|
|
export function ProfileInfoDialog({
|
|
isOpen,
|
|
onClose,
|
|
profile,
|
|
storedProxies,
|
|
vpnConfigs,
|
|
onOpenTrafficDialog,
|
|
onOpenProfileSyncDialog,
|
|
onAssignProfilesToGroup,
|
|
onConfigureCamoufox,
|
|
onCopyCookiesToProfile,
|
|
onOpenCookieManagement,
|
|
onAssignExtensionGroup,
|
|
onOpenBypassRules,
|
|
onOpenDnsBlocklist,
|
|
onOpenLaunchHook,
|
|
onCloneProfile,
|
|
onDeleteProfile,
|
|
onLaunchWithSync,
|
|
onSetPassword,
|
|
onChangePassword,
|
|
onRemovePassword,
|
|
crossOsUnlocked = false,
|
|
isRunning = false,
|
|
isDisabled = false,
|
|
isCrossOs = false,
|
|
syncStatuses,
|
|
}: ProfileInfoDialogProps) {
|
|
const { t } = useTranslation();
|
|
const [copied, setCopied] = React.useState(false);
|
|
const [groupName, setGroupName] = React.useState<string | null>(null);
|
|
const [extensionGroupName, setExtensionGroupName] = React.useState<
|
|
string | null
|
|
>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (!isOpen || !profile?.group_id) {
|
|
setGroupName(null);
|
|
return;
|
|
}
|
|
void (async () => {
|
|
try {
|
|
const groups = await invoke<ProfileGroup[]>("get_groups");
|
|
const group = groups.find((g) => g.id === profile.group_id);
|
|
setGroupName(group?.name ?? null);
|
|
} catch {
|
|
setGroupName(null);
|
|
}
|
|
})();
|
|
}, [isOpen, profile?.group_id]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isOpen || !profile?.extension_group_id) {
|
|
setExtensionGroupName(null);
|
|
return;
|
|
}
|
|
void (async () => {
|
|
try {
|
|
const group = await invoke<{ name: string } | null>(
|
|
"get_extension_group_for_profile",
|
|
{ profileId: profile.id },
|
|
);
|
|
setExtensionGroupName(group?.name ?? null);
|
|
} catch {
|
|
setExtensionGroupName(null);
|
|
}
|
|
})();
|
|
}, [isOpen, profile?.extension_group_id, profile?.id]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isOpen) {
|
|
setCopied(false);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
if (!profile) return null;
|
|
|
|
const ProfileIcon = getProfileIcon(profile);
|
|
const isCamoufoxOrWayfern =
|
|
profile.browser === "camoufox" || profile.browser === "wayfern";
|
|
const isDeleteDisabled = isRunning;
|
|
|
|
const proxyName = profile.proxy_id
|
|
? storedProxies.find((p) => p.id === profile.proxy_id)?.name
|
|
: null;
|
|
const vpnName = profile.vpn_id
|
|
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
|
|
: null;
|
|
const networkLabel = vpnName
|
|
? `VPN: ${vpnName}`
|
|
: proxyName
|
|
? `Proxy: ${proxyName}`
|
|
: t("profileInfo.values.none");
|
|
|
|
const syncStatus = syncStatuses[profile.id];
|
|
const syncMode = profile.sync_mode ?? "Disabled";
|
|
|
|
const handleCopyId = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(profile.id);
|
|
setCopied(true);
|
|
setTimeout(() => {
|
|
setCopied(false);
|
|
}, 2000);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const handleAction = (action: () => void) => {
|
|
onClose();
|
|
action();
|
|
};
|
|
|
|
const hasTags = profile.tags && profile.tags.length > 0;
|
|
const hasNote = !!profile.note;
|
|
|
|
// Items in the settings tab `actions` list MUST only open another dialog
|
|
// (or trigger a navigation/action that closes this one). Do NOT put inline
|
|
// settings UI — inputs, toggles, save buttons — directly in this dialog's
|
|
// settings tab. Each setting belongs in its own focused dialog (see
|
|
// `ProfileLaunchHookDialog`, `ProfileBypassRulesDialog`,
|
|
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
|
|
// a navigation hub.
|
|
interface ActionItem {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
destructive?: boolean;
|
|
proBadge?: boolean;
|
|
runningBadge?: boolean;
|
|
hidden?: boolean;
|
|
}
|
|
|
|
const actions: ActionItem[] = [
|
|
{
|
|
icon: <LuGlobe className="size-4" />,
|
|
label: t("profiles.actions.viewNetwork"),
|
|
onClick: () => {
|
|
handleAction(() => onOpenTrafficDialog?.(profile.id));
|
|
},
|
|
disabled: isCrossOs,
|
|
},
|
|
{
|
|
icon: <LuRefreshCw className="size-4" />,
|
|
label: t("profiles.actions.syncSettings"),
|
|
onClick: () => {
|
|
handleAction(() => onOpenProfileSyncDialog?.(profile));
|
|
},
|
|
disabled: isCrossOs,
|
|
hidden: profile.ephemeral === true,
|
|
},
|
|
{
|
|
icon: <LuGroup className="size-4" />,
|
|
label: t("profiles.actions.assignToGroup"),
|
|
onClick: () => {
|
|
handleAction(() => onAssignProfilesToGroup?.([profile.id]));
|
|
},
|
|
disabled: isDisabled,
|
|
runningBadge: isRunning,
|
|
},
|
|
{
|
|
icon: <LuFingerprint className="size-4" />,
|
|
label: t("profiles.actions.changeFingerprint"),
|
|
onClick: () => {
|
|
handleAction(() => onConfigureCamoufox?.(profile));
|
|
},
|
|
disabled: isDisabled,
|
|
runningBadge: isRunning,
|
|
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
|
|
},
|
|
{
|
|
icon: <LuUsers className="size-4" />,
|
|
label: t("profiles.synchronizer.launchWithSync"),
|
|
onClick: () => {
|
|
handleAction(() => onLaunchWithSync?.(profile));
|
|
},
|
|
disabled: isDisabled || isRunning || !crossOsUnlocked,
|
|
proBadge: !crossOsUnlocked,
|
|
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
|
|
},
|
|
{
|
|
icon: <LuCopy className="size-4" />,
|
|
label: t("profiles.actions.copyCookiesToProfile"),
|
|
onClick: () => {
|
|
handleAction(() => onCopyCookiesToProfile?.(profile));
|
|
},
|
|
disabled: isDisabled,
|
|
runningBadge: isRunning,
|
|
hidden:
|
|
!isCamoufoxOrWayfern ||
|
|
profile.ephemeral === true ||
|
|
!onCopyCookiesToProfile,
|
|
},
|
|
{
|
|
icon: <LuCookie className="size-4" />,
|
|
label: t("profileInfo.actions.manageCookies"),
|
|
onClick: () => {
|
|
handleAction(() => onOpenCookieManagement?.(profile));
|
|
},
|
|
disabled: isDisabled,
|
|
runningBadge: isRunning,
|
|
hidden:
|
|
!isCamoufoxOrWayfern ||
|
|
profile.ephemeral === true ||
|
|
!onOpenCookieManagement,
|
|
},
|
|
{
|
|
icon: <LuSettings className="size-4" />,
|
|
label: t("profiles.actions.clone"),
|
|
onClick: () => {
|
|
handleAction(() => onCloneProfile?.(profile));
|
|
},
|
|
disabled: isDisabled,
|
|
runningBadge: isRunning,
|
|
hidden: profile.ephemeral === true,
|
|
},
|
|
{
|
|
icon: <LuPuzzle className="size-4" />,
|
|
label: t("profileInfo.actions.assignExtensionGroup"),
|
|
onClick: () => {
|
|
handleAction(() => onAssignExtensionGroup?.([profile.id]));
|
|
},
|
|
disabled: isDisabled,
|
|
runningBadge: isRunning,
|
|
hidden: profile.ephemeral === true,
|
|
},
|
|
{
|
|
icon: <LuShieldCheck className="size-4" />,
|
|
label: t("profileInfo.network.bypassRulesTitle"),
|
|
onClick: () => {
|
|
handleAction(() => onOpenBypassRules?.(profile));
|
|
},
|
|
},
|
|
{
|
|
icon: <LuShield className="size-4" />,
|
|
label: t("dnsBlocklist.title"),
|
|
onClick: () => {
|
|
handleAction(() => onOpenDnsBlocklist?.(profile));
|
|
},
|
|
},
|
|
{
|
|
icon: <LuLink className="size-4" />,
|
|
label: t("profiles.actions.launchHook"),
|
|
onClick: () => {
|
|
handleAction(() => onOpenLaunchHook?.(profile));
|
|
},
|
|
hidden: !onOpenLaunchHook,
|
|
},
|
|
{
|
|
icon: <LuKey className="size-4" />,
|
|
label: t("profiles.actions.setPassword"),
|
|
onClick: () => {
|
|
handleAction(() => onSetPassword?.(profile));
|
|
},
|
|
disabled: isDisabled || isRunning,
|
|
runningBadge: isRunning,
|
|
hidden:
|
|
profile.password_protected === true ||
|
|
profile.ephemeral === true ||
|
|
!onSetPassword,
|
|
},
|
|
{
|
|
icon: <LuKey className="size-4" />,
|
|
label: t("profiles.actions.changePassword"),
|
|
onClick: () => {
|
|
handleAction(() => onChangePassword?.(profile));
|
|
},
|
|
disabled: isDisabled || isRunning,
|
|
runningBadge: isRunning,
|
|
hidden: profile.password_protected !== true || !onChangePassword,
|
|
},
|
|
{
|
|
icon: <LuLockOpen className="size-4" />,
|
|
label: t("profiles.actions.removePassword"),
|
|
onClick: () => {
|
|
handleAction(() => onRemovePassword?.(profile));
|
|
},
|
|
disabled: isDisabled || isRunning,
|
|
runningBadge: isRunning,
|
|
hidden: profile.password_protected !== true || !onRemovePassword,
|
|
destructive: true,
|
|
},
|
|
{
|
|
icon: <LuTrash2 className="size-4" />,
|
|
label: t("profiles.actions.delete"),
|
|
onClick: () => {
|
|
handleAction(() => onDeleteProfile?.(profile));
|
|
},
|
|
disabled: isDeleteDisabled,
|
|
destructive: true,
|
|
},
|
|
];
|
|
|
|
const visibleActions = actions.filter((a) => !a.hidden);
|
|
|
|
return (
|
|
<Dialog
|
|
open={isOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) onClose();
|
|
}}
|
|
>
|
|
<DialogContent
|
|
hideClose
|
|
className="sm:max-w-3xl w-[720px] max-w-[720px] h-[480px] max-h-[480px] flex flex-col p-0 gap-0 overflow-hidden"
|
|
>
|
|
<ProfileInfoLayout
|
|
profile={profile}
|
|
ProfileIcon={ProfileIcon}
|
|
isRunning={isRunning}
|
|
isDisabled={isDisabled}
|
|
networkLabel={networkLabel}
|
|
groupName={groupName}
|
|
extensionGroupName={extensionGroupName}
|
|
syncMode={syncMode}
|
|
syncStatus={syncStatus}
|
|
storedProxies={storedProxies}
|
|
vpnConfigs={vpnConfigs}
|
|
hasTags={hasTags}
|
|
hasNote={hasNote}
|
|
copied={copied}
|
|
handleCopyId={handleCopyId}
|
|
onClose={onClose}
|
|
onCloneProfile={onCloneProfile}
|
|
onKillProfile={undefined}
|
|
visibleActions={visibleActions}
|
|
t={t}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
interface ProfileInfoLayoutProps {
|
|
profile: BrowserProfile;
|
|
ProfileIcon: React.ComponentType<{ className?: string }>;
|
|
isRunning: boolean;
|
|
isDisabled: boolean;
|
|
networkLabel: string;
|
|
groupName: string | null;
|
|
extensionGroupName: string | null;
|
|
syncMode: string;
|
|
syncStatus: { status: string; error?: string } | undefined;
|
|
hasTags: boolean | undefined;
|
|
hasNote: boolean;
|
|
copied: boolean;
|
|
storedProxies: StoredProxy[];
|
|
vpnConfigs: VpnConfig[];
|
|
handleCopyId: () => Promise<void>;
|
|
onClose: () => void;
|
|
onCloneProfile?: (profile: BrowserProfile) => void;
|
|
onKillProfile?: (profile: BrowserProfile) => void;
|
|
visibleActions: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
destructive?: boolean;
|
|
proBadge?: boolean;
|
|
runningBadge?: boolean;
|
|
}[];
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
}
|
|
|
|
type ProfileSection =
|
|
| "overview"
|
|
| "fingerprint"
|
|
| "network"
|
|
| "cookies"
|
|
| "extensions"
|
|
| "sync"
|
|
| "automation"
|
|
| "security"
|
|
| "delete";
|
|
|
|
function ProfileInfoLayout({
|
|
profile,
|
|
ProfileIcon,
|
|
isRunning,
|
|
isDisabled,
|
|
networkLabel,
|
|
groupName,
|
|
extensionGroupName,
|
|
syncMode,
|
|
syncStatus,
|
|
storedProxies,
|
|
vpnConfigs,
|
|
hasTags,
|
|
hasNote,
|
|
copied,
|
|
handleCopyId,
|
|
onClose,
|
|
onCloneProfile,
|
|
visibleActions,
|
|
t,
|
|
}: ProfileInfoLayoutProps) {
|
|
const [section, setSection] = React.useState<ProfileSection>("overview");
|
|
|
|
// Map sidebar items to existing action labels, so clicking a section
|
|
// simply triggers the existing dialog handler.
|
|
const findAction = React.useCallback(
|
|
(substr: string) =>
|
|
visibleActions.find((a) => a.label.toLowerCase().includes(substr)),
|
|
[visibleActions],
|
|
);
|
|
|
|
const deleteAction = findAction("delete");
|
|
const fingerprintAction = findAction("fingerprint");
|
|
const cookiesManageAction = findAction("manage cookies");
|
|
const cookiesCopyAction = findAction("copy cookies");
|
|
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
|
|
const extensionAction = findAction("extension");
|
|
const syncAction = findAction("sync");
|
|
const _launchHookAction = findAction("hook") ?? findAction("launch hook");
|
|
const _networkAction = findAction("network");
|
|
// Password actions are no longer routed via the legacy action handlers —
|
|
// SecuritySectionInline writes directly to the backend instead.
|
|
|
|
// Cookie count is fetched at the layout level so the sidebar badge can
|
|
// surface it without waiting for the user to open the Cookies section.
|
|
// The effect deps must be primitive — `cookiesAction` is a new object
|
|
// every render and using it directly here caused an infinite re-render
|
|
// loop that froze the entire app when the dialog opened.
|
|
// Skipped while running: the cookie DB is held by the browser and we
|
|
// don't want to compete for its lock from the badge fetch.
|
|
const cookiesSupported = !!cookiesAction;
|
|
const [cookieCount, setCookieCount] = React.useState<number | null>(null);
|
|
React.useEffect(() => {
|
|
if (!cookiesSupported || isRunning) {
|
|
setCookieCount(null);
|
|
return;
|
|
}
|
|
let mounted = true;
|
|
void (async () => {
|
|
try {
|
|
const data = await invoke<{ total_count: number }>(
|
|
"get_profile_cookie_stats",
|
|
{ profileId: profile.id },
|
|
);
|
|
if (mounted) setCookieCount(data.total_count);
|
|
} catch {
|
|
if (mounted) setCookieCount(null);
|
|
}
|
|
})();
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, [profile.id, cookiesSupported, isRunning]);
|
|
|
|
const sidebarItems: {
|
|
id: ProfileSection;
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
badge?: string;
|
|
destructive?: boolean;
|
|
hidden?: boolean;
|
|
}[] = [
|
|
{
|
|
id: "overview",
|
|
icon: <LuClipboard className="size-3.5" />,
|
|
label: t("profileInfo.sections.overview"),
|
|
},
|
|
{
|
|
id: "fingerprint",
|
|
icon: <LuFingerprint className="size-3.5" />,
|
|
label: t("profileInfo.sections.fingerprint"),
|
|
badge: profile.password_protected
|
|
? t("profileInfo.badges.locked")
|
|
: undefined,
|
|
hidden: !fingerprintAction,
|
|
},
|
|
{
|
|
id: "network",
|
|
icon: <LuGlobe className="size-3.5" />,
|
|
label: t("profileInfo.sections.network"),
|
|
badge: profile.proxy_id || profile.vpn_id ? networkLabel : undefined,
|
|
},
|
|
{
|
|
id: "cookies",
|
|
icon: <LuCookie className="size-3.5" />,
|
|
label: t("profileInfo.sections.cookies"),
|
|
badge:
|
|
cookieCount !== null && cookieCount > 0
|
|
? cookieCount.toLocaleString()
|
|
: undefined,
|
|
hidden: !cookiesAction,
|
|
},
|
|
{
|
|
id: "extensions",
|
|
icon: <LuPuzzle className="size-3.5" />,
|
|
label: t("profileInfo.sections.extensions"),
|
|
badge: extensionGroupName ?? undefined,
|
|
hidden: !extensionAction,
|
|
},
|
|
{
|
|
id: "sync",
|
|
icon: <LuRefreshCw className="size-3.5" />,
|
|
label: t("profileInfo.sections.sync"),
|
|
hidden: !syncAction,
|
|
},
|
|
{
|
|
id: "automation",
|
|
icon: <LuLink className="size-3.5" />,
|
|
label: t("profileInfo.sections.launchHook"),
|
|
badge: profile.launch_hook ? t("profileInfo.badges.active") : undefined,
|
|
},
|
|
{
|
|
id: "security",
|
|
icon: <LuKey className="size-3.5" />,
|
|
label: t("profileInfo.sections.security"),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
{/* Top bar */}
|
|
<div className="flex items-center gap-2 h-11 px-3 border-b border-border shrink-0">
|
|
<LuUsers className="size-3.5 text-muted-foreground shrink-0" />
|
|
<div className="flex items-center gap-1.5 text-xs min-w-0 flex-1">
|
|
<span className="font-semibold">
|
|
{t("profileInfo.breadcrumbRoot")}
|
|
</span>
|
|
<span className="text-muted-foreground">/</span>
|
|
<span className="text-muted-foreground truncate">{profile.name}</span>
|
|
</div>
|
|
{onCloneProfile && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs gap-1.5"
|
|
disabled={isDisabled}
|
|
onClick={() => onCloneProfile(profile)}
|
|
>
|
|
<LuCopy className="size-3" />
|
|
{t("profileInfo.duplicate")}
|
|
</Button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
aria-label={t("common.buttons.close")}
|
|
onClick={onClose}
|
|
className="grid place-items-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors duration-100"
|
|
>
|
|
<LuX className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex flex-1 min-h-0">
|
|
{/* Sidebar */}
|
|
<nav className="w-44 shrink-0 border-r border-border p-2 flex flex-col gap-0.5 overflow-y-auto">
|
|
{sidebarItems
|
|
.filter((it) => !it.hidden)
|
|
.map((it) => {
|
|
const active = section === it.id;
|
|
return (
|
|
<button
|
|
key={it.id}
|
|
type="button"
|
|
onClick={() => setSection(it.id)}
|
|
className={cn(
|
|
"flex items-center gap-2 h-7 px-2 rounded-md text-xs transition-colors duration-100 text-left",
|
|
active
|
|
? "bg-accent text-accent-foreground"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
|
)}
|
|
>
|
|
<span className="shrink-0">{it.icon}</span>
|
|
<span className="flex-1 truncate">{it.label}</span>
|
|
{it.badge && (
|
|
<span className="text-[9px] uppercase text-muted-foreground tracking-wide truncate max-w-[60px]">
|
|
{it.badge}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
{deleteAction && (
|
|
<>
|
|
<div className="my-1 h-px bg-border" />
|
|
<button
|
|
type="button"
|
|
onClick={deleteAction.onClick}
|
|
disabled={deleteAction.disabled}
|
|
className="flex items-center gap-2 h-7 px-2 rounded-md text-xs transition-colors duration-100 text-destructive hover:bg-destructive/10 disabled:opacity-50 disabled:pointer-events-none"
|
|
>
|
|
<LuTrash2 className="size-3.5 shrink-0" />
|
|
<span className="flex-1 text-left">
|
|
{t("profileInfo.sections.delete")}
|
|
</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</nav>
|
|
|
|
{/* Main */}
|
|
<div className="flex-1 min-w-0 overflow-y-auto scroll-fade p-4">
|
|
{section === "overview" && (
|
|
<div className="flex flex-col gap-3">
|
|
{/* Hero */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-lg bg-muted p-2.5 shrink-0">
|
|
<ProfileIcon className="size-7 text-foreground" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<h3 className="text-base font-semibold truncate">
|
|
{profile.name}
|
|
</h3>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px]">
|
|
<span className="font-mono text-muted-foreground">
|
|
{profile.version}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ID */}
|
|
<div className="flex items-center gap-2 rounded-md bg-muted/40 px-3 py-2 border border-border">
|
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0">
|
|
ID
|
|
</span>
|
|
<span className="font-mono text-xs truncate flex-1">
|
|
{profile.id}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCopyId()}
|
|
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
|
aria-label={t("common.buttons.copy")}
|
|
>
|
|
{copied ? (
|
|
<LuClipboardCheck className="size-3.5" />
|
|
) : (
|
|
<LuClipboard className="size-3.5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* 2x2 cards */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<InfoCard
|
|
label={t("profileInfo.fields.group")}
|
|
value={groupName ?? t("profileInfo.values.none")}
|
|
/>
|
|
<InfoCard
|
|
label={t("profileInfo.fields.proxyVpn")}
|
|
value={networkLabel}
|
|
/>
|
|
<InfoCard
|
|
label={t("profileInfo.fields.tags")}
|
|
value={
|
|
hasTags
|
|
? (profile.tags ?? []).join(", ")
|
|
: t("profileInfo.values.none")
|
|
}
|
|
/>
|
|
<InfoCard
|
|
label={t("profileInfo.fields.note")}
|
|
value={
|
|
hasNote
|
|
? (profile.note ?? "")
|
|
: t("profileInfo.values.none")
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Activity */}
|
|
<div className="mt-1 flex flex-col gap-1.5">
|
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
{t("profileInfo.sections.activity")}
|
|
</span>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<InfoCard
|
|
label={t("profileInfo.fields.lastLaunched")}
|
|
value={
|
|
isRunning
|
|
? t("profileInfo.values.activeNow")
|
|
: profile.last_launch
|
|
? formatRelativeTime(profile.last_launch)
|
|
: t("profileInfo.values.never")
|
|
}
|
|
/>
|
|
<LocalDataTransferCard profileId={profile.id} t={t} />
|
|
</div>
|
|
</div>
|
|
|
|
{profile.created_by_email && (
|
|
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
{t("sync.team.title")}
|
|
</p>
|
|
<p className="text-sm mt-0.5">
|
|
{t("sync.team.createdBy", {
|
|
email: profile.created_by_email,
|
|
})}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{section === "fingerprint" && (
|
|
<FingerprintSectionInline
|
|
profile={profile}
|
|
isDisabled={isDisabled}
|
|
crossOsUnlocked={Boolean(
|
|
// Re-derive: parent passes crossOsUnlocked but the layout
|
|
// doesn't get it; we get it implicitly via fingerprintAction's
|
|
// proBadge state. Default to false if action missing.
|
|
fingerprintAction && !fingerprintAction.proBadge,
|
|
)}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{section === "network" && (
|
|
<NetworkSectionInline
|
|
profile={profile}
|
|
storedProxies={storedProxies}
|
|
vpnConfigs={vpnConfigs}
|
|
isDisabled={isDisabled}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{section === "cookies" && (
|
|
<CookiesSectionInline
|
|
profile={profile}
|
|
isRunning={isRunning}
|
|
isDisabled={isDisabled}
|
|
onCopyCookies={cookiesCopyAction?.onClick}
|
|
onImportCookies={cookiesManageAction?.onClick}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{section === "extensions" && (
|
|
<ExtensionsSectionInline
|
|
profile={profile}
|
|
isDisabled={isDisabled}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{section === "sync" && (
|
|
<SyncSectionInline
|
|
profile={profile}
|
|
syncMode={syncMode}
|
|
syncStatus={syncStatus}
|
|
isDisabled={isDisabled}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{section === "automation" && (
|
|
<LaunchHookEditor profile={profile} t={t} />
|
|
)}
|
|
|
|
{section === "security" && (
|
|
<SecuritySectionInline
|
|
profile={profile}
|
|
isRunning={isRunning}
|
|
t={t}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function _SectionPlaceholder({
|
|
icon,
|
|
title,
|
|
description,
|
|
actionLabel,
|
|
onAction,
|
|
disabled,
|
|
hint,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
description: string;
|
|
actionLabel: string;
|
|
onAction: () => void;
|
|
disabled?: boolean;
|
|
hint?: string;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
{icon}
|
|
{title}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{description}</p>
|
|
{hint && (
|
|
<div className="rounded-md bg-muted/40 border border-border px-3 py-2 text-xs">
|
|
{hint}
|
|
</div>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
onClick={onAction}
|
|
disabled={disabled}
|
|
className="self-start h-7 text-xs"
|
|
>
|
|
{actionLabel}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function _SectionAction({
|
|
icon,
|
|
label,
|
|
onClick,
|
|
disabled,
|
|
destructive,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
destructive?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
className={cn(
|
|
"flex items-center gap-2 h-9 px-3 rounded-md text-xs transition-colors text-left",
|
|
destructive
|
|
? "text-destructive hover:bg-destructive/10"
|
|
: "hover:bg-accent",
|
|
"disabled:opacity-50 disabled:pointer-events-none",
|
|
)}
|
|
>
|
|
{icon}
|
|
<span className="flex-1">{label}</span>
|
|
<LuChevronRight className="size-3.5 text-muted-foreground" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function isValidHttpUrl(value: string): boolean {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return false;
|
|
try {
|
|
const u = new URL(trimmed);
|
|
return u.protocol === "http:" || u.protocol === "https:";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function LaunchHookEditor({
|
|
profile,
|
|
t,
|
|
}: {
|
|
profile: BrowserProfile;
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
}) {
|
|
const { t: tFn } = useTranslation();
|
|
const [value, setValue] = React.useState(profile.launch_hook ?? "");
|
|
const [isSaving, setIsSaving] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const initial = profile.launch_hook ?? "";
|
|
const dirty = value !== initial;
|
|
const trimmed = value.trim();
|
|
const showInvalidHint = trimmed.length > 0 && !isValidHttpUrl(trimmed);
|
|
|
|
const onSave = async () => {
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await invoke("update_profile_launch_hook", {
|
|
profileId: profile.id,
|
|
launchHook: trimmed ? trimmed : null,
|
|
});
|
|
} catch (e) {
|
|
setError(translateBackendError(tFn, e));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
<LuLink className="size-4" />
|
|
{t("profileInfo.sections.launchHook")}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("profileInfo.sectionDesc.launchHook")}
|
|
</p>
|
|
<Input
|
|
type="url"
|
|
value={value}
|
|
onChange={(e) => {
|
|
setValue(e.target.value);
|
|
}}
|
|
placeholder={t("profiles.launchHook.placeholder")}
|
|
className="text-xs font-mono"
|
|
/>
|
|
{showInvalidHint && (
|
|
<p className="text-xs text-warning">
|
|
{t("profileInfo.launchHook.invalidUrlHint")}
|
|
</p>
|
|
)}
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
disabled={!dirty || isSaving || showInvalidHint}
|
|
onClick={() => {
|
|
void onSave();
|
|
}}
|
|
>
|
|
{isSaving ? t("common.buttons.saving") : t("common.buttons.save")}
|
|
</Button>
|
|
{dirty && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 text-xs"
|
|
onClick={() => {
|
|
setValue(initial);
|
|
setError(null);
|
|
}}
|
|
>
|
|
{t("common.buttons.cancel")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SyncSectionInline({
|
|
profile,
|
|
syncMode,
|
|
syncStatus,
|
|
isDisabled,
|
|
t,
|
|
}: {
|
|
profile: BrowserProfile;
|
|
syncMode: string;
|
|
syncStatus: { status: string; error?: string } | undefined;
|
|
isDisabled: boolean;
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
}) {
|
|
const [isSaving, setIsSaving] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
const onChangeMode = async (mode: string) => {
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await invoke("set_profile_sync_mode", {
|
|
profileId: profile.id,
|
|
syncMode: mode,
|
|
});
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
<LuRefreshCw className="size-4" />
|
|
{t("profileInfo.sections.sync")}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("profileInfo.sectionDesc.sync")}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0">
|
|
{t("profileInfo.fields.syncMode")}
|
|
</span>
|
|
<Select
|
|
value={syncMode}
|
|
disabled={isDisabled || isSaving}
|
|
onValueChange={(v) => {
|
|
void onChangeMode(v);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs flex-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="Disabled">{t("sync.mode.disabled")}</SelectItem>
|
|
<SelectItem value="Regular">{t("sync.mode.regular")}</SelectItem>
|
|
<SelectItem value="Encrypted">
|
|
{t("sync.mode.encrypted")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{syncStatus && (
|
|
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
{t("profileInfo.fields.syncStatus")}
|
|
</p>
|
|
<p className="text-sm mt-0.5">{syncStatus.status}</p>
|
|
{syncStatus.error && (
|
|
<p className="text-xs text-destructive mt-1">{syncStatus.error}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NetworkSectionInline({
|
|
profile,
|
|
storedProxies,
|
|
vpnConfigs,
|
|
isDisabled,
|
|
t,
|
|
}: {
|
|
profile: BrowserProfile;
|
|
storedProxies: StoredProxy[];
|
|
vpnConfigs: VpnConfig[];
|
|
isDisabled: boolean;
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
}) {
|
|
const [isSaving, setIsSaving] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
// Track the effective selection so the dropdown reflects the last save
|
|
// even before the parent `profile` prop refreshes from a backend event.
|
|
const [proxyId, setProxyId] = React.useState<string | null>(
|
|
profile.proxy_id ?? null,
|
|
);
|
|
const [vpnId, setVpnId] = React.useState<string | null>(
|
|
profile.vpn_id ?? null,
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
setProxyId(profile.proxy_id ?? null);
|
|
setVpnId(profile.vpn_id ?? null);
|
|
}, [profile.proxy_id, profile.vpn_id]);
|
|
|
|
const onProxyChange = async (value: string) => {
|
|
const nextId = value === "__none__" ? null : value;
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await invoke("update_profile_proxy", {
|
|
profileId: profile.id,
|
|
proxyId: nextId,
|
|
});
|
|
// Clearing the proxy implicitly clears any VPN binding too on the
|
|
// backend, but we mirror it locally for an immediate visual.
|
|
setProxyId(nextId);
|
|
if (nextId !== null) setVpnId(null);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const onVpnChange = async (value: string) => {
|
|
const nextId = value === "__none__" ? null : value;
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await invoke("update_profile_vpn", {
|
|
profileId: profile.id,
|
|
vpnId: nextId,
|
|
});
|
|
setVpnId(nextId);
|
|
if (nextId !== null) setProxyId(null);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
<LuGlobe className="size-4" />
|
|
{t("profileInfo.sections.network")}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("profileInfo.sectionDesc.network")}
|
|
</p>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0 w-12">
|
|
{t("profileInfo.fields.proxy")}
|
|
</span>
|
|
<Select
|
|
value={proxyId ?? "__none__"}
|
|
disabled={isDisabled || isSaving}
|
|
onValueChange={(v) => {
|
|
void onProxyChange(v);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs flex-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">
|
|
{t("profileInfo.values.none")}
|
|
</SelectItem>
|
|
{storedProxies.map((p) => (
|
|
<SelectItem key={p.id} value={p.id}>
|
|
{p.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0 w-12">
|
|
{t("profileInfo.fields.vpn")}
|
|
</span>
|
|
<Select
|
|
value={vpnId ?? "__none__"}
|
|
disabled={isDisabled || isSaving}
|
|
onValueChange={(v) => {
|
|
void onVpnChange(v);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs flex-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">
|
|
{t("profileInfo.values.none")}
|
|
</SelectItem>
|
|
{vpnConfigs.map((v) => (
|
|
<SelectItem key={v.id} value={v.id}>
|
|
{v.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ExtensionsSectionInline({
|
|
profile,
|
|
isDisabled,
|
|
t,
|
|
}: {
|
|
profile: BrowserProfile;
|
|
isDisabled: boolean;
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
}) {
|
|
type ExtensionGroupOption = { id: string; name: string };
|
|
const [groups, setGroups] = React.useState<ExtensionGroupOption[]>([]);
|
|
const [groupId, setGroupId] = React.useState<string | null>(
|
|
profile.extension_group_id ?? null,
|
|
);
|
|
const [isSaving, setIsSaving] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
setGroupId(profile.extension_group_id ?? null);
|
|
}, [profile.extension_group_id]);
|
|
|
|
React.useEffect(() => {
|
|
let mounted = true;
|
|
let unlisten: (() => void) | undefined;
|
|
const load = async () => {
|
|
try {
|
|
const data = await invoke<ExtensionGroupOption[]>(
|
|
"list_extension_groups",
|
|
);
|
|
if (mounted) setGroups(data);
|
|
} catch (e) {
|
|
if (mounted) setError(String(e));
|
|
}
|
|
};
|
|
void load();
|
|
void listen("extensions-changed", () => {
|
|
void load();
|
|
}).then((u) => {
|
|
if (mounted) unlisten = u;
|
|
else u();
|
|
});
|
|
return () => {
|
|
mounted = false;
|
|
unlisten?.();
|
|
};
|
|
}, []);
|
|
|
|
const onChange = async (value: string) => {
|
|
const next = value === "__none__" ? null : value;
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await invoke("assign_extension_group_to_profile", {
|
|
profileId: profile.id,
|
|
extensionGroupId: next,
|
|
});
|
|
setGroupId(next);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
<LuPuzzle className="size-4" />
|
|
{t("profileInfo.sections.extensions")}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("profileInfo.sectionDesc.extensions")}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0 w-16">
|
|
{t("profileInfo.fields.extensionGroup")}
|
|
</span>
|
|
<Select
|
|
value={groupId ?? "__none__"}
|
|
disabled={isDisabled || isSaving}
|
|
onValueChange={(v) => {
|
|
void onChange(v);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs flex-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">
|
|
{t("profileInfo.values.none")}
|
|
</SelectItem>
|
|
{groups.map((g) => (
|
|
<SelectItem key={g.id} value={g.id}>
|
|
{g.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CookiesSectionInline({
|
|
profile,
|
|
isRunning,
|
|
isDisabled,
|
|
onCopyCookies,
|
|
onImportCookies,
|
|
t,
|
|
}: {
|
|
profile: BrowserProfile;
|
|
isRunning: boolean;
|
|
isDisabled: boolean;
|
|
onCopyCookies?: () => void;
|
|
onImportCookies?: () => void;
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
}) {
|
|
type CookieStats = {
|
|
profile_id: string;
|
|
browser_type: string;
|
|
total_count: number;
|
|
domains: { domain: string; count: number }[];
|
|
};
|
|
const [stats, setStats] = React.useState<CookieStats | null>(null);
|
|
const [isLoading, setIsLoading] = React.useState(!isRunning);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (isRunning) {
|
|
setStats(null);
|
|
setIsLoading(false);
|
|
setError(null);
|
|
return;
|
|
}
|
|
let mounted = true;
|
|
setIsLoading(true);
|
|
setError(null);
|
|
void (async () => {
|
|
try {
|
|
const data = await invoke<CookieStats>("get_profile_cookie_stats", {
|
|
profileId: profile.id,
|
|
});
|
|
if (mounted) setStats(data);
|
|
} catch (e) {
|
|
if (mounted) setError(translateBackendError(t as never, e));
|
|
} finally {
|
|
if (mounted) setIsLoading(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, [profile.id, isRunning, t]);
|
|
|
|
const domains = stats?.domains ?? [];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3 min-h-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
<LuCookie className="size-4" />
|
|
{t("profileInfo.sections.cookies")}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{onImportCookies && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 gap-1.5"
|
|
disabled={isDisabled || isRunning}
|
|
onClick={onImportCookies}
|
|
>
|
|
<LuUpload className="size-3.5" />
|
|
{t("cookies.import.title")}
|
|
</Button>
|
|
)}
|
|
{onCopyCookies && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 gap-1.5"
|
|
disabled={isDisabled}
|
|
onClick={onCopyCookies}
|
|
>
|
|
<LuCopy className="size-3.5" />
|
|
{t("profiles.actions.copyCookies")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("profileInfo.sectionDesc.cookies")}
|
|
</p>
|
|
{isRunning ? (
|
|
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("profileInfo.cookies.runningNotice")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
{t("profileInfo.fields.cookieCount")}
|
|
</p>
|
|
<p className="text-sm mt-0.5">
|
|
{isLoading
|
|
? t("profileInfo.values.loading")
|
|
: stats
|
|
? stats.total_count.toLocaleString()
|
|
: "—"}
|
|
</p>
|
|
</div>
|
|
{domains.length > 0 && (
|
|
<div className="rounded-md bg-muted/40 border border-border flex flex-col min-h-0 flex-1 overflow-hidden">
|
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground px-3 py-2 border-b border-border shrink-0">
|
|
{t("profileInfo.cookies.domainsHeader", {
|
|
count: domains.length,
|
|
})}
|
|
</p>
|
|
<ul className="text-xs px-3 py-2 overflow-y-auto flex-1 space-y-1">
|
|
{domains.map((d) => (
|
|
<li
|
|
key={d.domain}
|
|
className="flex items-center justify-between gap-2"
|
|
>
|
|
<span className="truncate font-mono">{d.domain}</span>
|
|
<span className="text-muted-foreground tabular-nums">
|
|
{d.count}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Inline password set / change / remove form. Replaces three separate
|
|
// nested modal dialogs with one in-page form that branches on the current
|
|
// `password_protected` state of the profile.
|
|
// Inline fingerprint editor. Reuses SharedCamoufoxConfigForm (Camoufox/Firefox
|
|
// engine) and WayfernConfigForm (Chromium engine) so the same field set as
|
|
// the standalone dialog is available without opening a nested modal.
|
|
function FingerprintSectionInline({
|
|
profile,
|
|
isDisabled,
|
|
crossOsUnlocked,
|
|
t,
|
|
}: {
|
|
profile: BrowserProfile;
|
|
isDisabled: boolean;
|
|
crossOsUnlocked: boolean;
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
}) {
|
|
const [camoufoxConfig, setCamoufoxConfig] = React.useState<CamoufoxConfig>(
|
|
() => profile.camoufox_config ?? {},
|
|
);
|
|
const [wayfernConfig, setWayfernConfig] = React.useState<WayfernConfig>(
|
|
() => profile.wayfern_config ?? {},
|
|
);
|
|
const [isSaving, setIsSaving] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [success, setSuccess] = React.useState<string | null>(null);
|
|
|
|
// When the underlying profile changes (e.g. a different profile is opened
|
|
// in the dialog) reset the local form state to match.
|
|
React.useEffect(() => {
|
|
setCamoufoxConfig(profile.camoufox_config ?? {});
|
|
setWayfernConfig(profile.wayfern_config ?? {});
|
|
setError(null);
|
|
setSuccess(null);
|
|
}, [profile.camoufox_config, profile.wayfern_config]);
|
|
|
|
const isCamoufox = profile.browser === "camoufox";
|
|
const isWayfern = profile.browser === "wayfern";
|
|
|
|
if (!isCamoufox && !isWayfern) {
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
<LuFingerprint className="size-4" />
|
|
{t("profileInfo.sections.fingerprint")}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("profileInfo.fingerprint.notSupported")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const onCamoufoxChange = (key: keyof CamoufoxConfig, value: unknown) => {
|
|
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
|
setSuccess(null);
|
|
};
|
|
const onWayfernChange = (key: keyof WayfernConfig, value: unknown) => {
|
|
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
|
setSuccess(null);
|
|
};
|
|
|
|
const onSave = async () => {
|
|
setIsSaving(true);
|
|
setError(null);
|
|
setSuccess(null);
|
|
try {
|
|
if (isCamoufox) {
|
|
await invoke("update_camoufox_config", {
|
|
profileId: profile.id,
|
|
config: camoufoxConfig,
|
|
});
|
|
} else {
|
|
await invoke("update_wayfern_config", {
|
|
profileId: profile.id,
|
|
config: wayfernConfig,
|
|
});
|
|
}
|
|
setSuccess(t("common.buttons.saved"));
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const initial = isCamoufox
|
|
? JSON.stringify(profile.camoufox_config ?? {})
|
|
: JSON.stringify(profile.wayfern_config ?? {});
|
|
const current = isCamoufox
|
|
? JSON.stringify(camoufoxConfig)
|
|
: JSON.stringify(wayfernConfig);
|
|
const dirty = current !== initial;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
<LuFingerprint className="size-4" />
|
|
{t("profileInfo.sections.fingerprint")}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("profileInfo.sectionDesc.fingerprint")}
|
|
</p>
|
|
|
|
{isCamoufox && (
|
|
<SharedCamoufoxConfigForm
|
|
config={camoufoxConfig}
|
|
onConfigChange={onCamoufoxChange}
|
|
forceAdvanced={true}
|
|
readOnly={isDisabled}
|
|
browserType="camoufox"
|
|
crossOsUnlocked={crossOsUnlocked}
|
|
limitedMode={false}
|
|
profileVersion={profile.version}
|
|
profileBrowser={profile.browser}
|
|
/>
|
|
)}
|
|
{isWayfern && (
|
|
<WayfernConfigForm
|
|
config={wayfernConfig}
|
|
onConfigChange={onWayfernChange}
|
|
forceAdvanced={true}
|
|
readOnly={isDisabled}
|
|
crossOsUnlocked={crossOsUnlocked}
|
|
profileVersion={profile.version}
|
|
profileBrowser={profile.browser}
|
|
/>
|
|
)}
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
{success && !error && <p className="text-xs text-success">{success}</p>}
|
|
|
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
|
|
<Button
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
disabled={!dirty || isSaving || isDisabled}
|
|
onClick={() => {
|
|
void onSave();
|
|
}}
|
|
>
|
|
{isSaving ? t("common.buttons.saving") : t("common.buttons.save")}
|
|
</Button>
|
|
{dirty && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 text-xs"
|
|
onClick={() => {
|
|
setCamoufoxConfig(profile.camoufox_config ?? {});
|
|
setWayfernConfig(profile.wayfern_config ?? {});
|
|
setError(null);
|
|
setSuccess(null);
|
|
}}
|
|
>
|
|
{t("common.buttons.cancel")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SecuritySectionInline({
|
|
profile,
|
|
isRunning,
|
|
t,
|
|
}: {
|
|
profile: BrowserProfile;
|
|
isRunning: boolean;
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
}) {
|
|
// Mode is implied by current state: unprotected → "set"; protected →
|
|
// "change" by default, with a "remove" alternative.
|
|
type Mode = "set" | "change" | "remove";
|
|
const initialMode: Mode = profile.password_protected ? "change" : "set";
|
|
const [mode, setMode] = React.useState<Mode>(initialMode);
|
|
const [oldPassword, setOldPassword] = React.useState("");
|
|
const [password, setPassword] = React.useState("");
|
|
const [confirm, setConfirm] = React.useState("");
|
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [success, setSuccess] = React.useState<string | null>(null);
|
|
const [isVerifyOpen, setIsVerifyOpen] = React.useState(false);
|
|
const [verifyPassword, setVerifyPassword] = React.useState("");
|
|
const [isVerifying, setIsVerifying] = React.useState(false);
|
|
|
|
const onVerify = async () => {
|
|
setIsVerifying(true);
|
|
try {
|
|
await invoke("verify_profile_password", {
|
|
profileId: profile.id,
|
|
password: verifyPassword,
|
|
});
|
|
showSuccessToast(t("profilePassword.verifyDialog.matchToast"));
|
|
setIsVerifyOpen(false);
|
|
setVerifyPassword("");
|
|
} catch (e) {
|
|
const message = translateBackendError(
|
|
t as unknown as Parameters<typeof translateBackendError>[0],
|
|
e,
|
|
);
|
|
showErrorToast(message);
|
|
} finally {
|
|
setIsVerifying(false);
|
|
}
|
|
};
|
|
|
|
// Reset the form whenever the underlying profile state changes (e.g. the
|
|
// user just set a password — flip to "change" mode and clear fields).
|
|
React.useEffect(() => {
|
|
setMode(profile.password_protected ? "change" : "set");
|
|
setOldPassword("");
|
|
setPassword("");
|
|
setConfirm("");
|
|
setError(null);
|
|
setSuccess(null);
|
|
}, [profile.password_protected]);
|
|
|
|
const reset = () => {
|
|
setOldPassword("");
|
|
setPassword("");
|
|
setConfirm("");
|
|
setError(null);
|
|
};
|
|
|
|
const validate = (): string | null => {
|
|
if (mode === "change" || mode === "remove") {
|
|
if (!oldPassword) return t("profilePassword.errors.passwordRequired");
|
|
}
|
|
if (mode === "set" || mode === "change") {
|
|
if (password.length < 8) return t("profilePassword.errors.tooShort");
|
|
if (password !== confirm)
|
|
return t("profilePassword.errors.passwordMismatch");
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const onSubmit = async () => {
|
|
if (isRunning) return;
|
|
const v = validate();
|
|
if (v) {
|
|
setError(v);
|
|
return;
|
|
}
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
setSuccess(null);
|
|
try {
|
|
if (mode === "set") {
|
|
await invoke("set_profile_password", {
|
|
profileId: profile.id,
|
|
password,
|
|
});
|
|
showSuccessToast(t("profilePassword.toasts.set"));
|
|
} else if (mode === "change") {
|
|
await invoke("change_profile_password", {
|
|
profileId: profile.id,
|
|
oldPassword,
|
|
newPassword: password,
|
|
});
|
|
showSuccessToast(t("profilePassword.toasts.changed"));
|
|
} else {
|
|
await invoke("remove_profile_password", {
|
|
profileId: profile.id,
|
|
password: oldPassword,
|
|
});
|
|
showSuccessToast(t("profilePassword.toasts.removed"));
|
|
}
|
|
reset();
|
|
} catch (e) {
|
|
const message = translateBackendError(
|
|
t as unknown as Parameters<typeof translateBackendError>[0],
|
|
e,
|
|
);
|
|
setError(message);
|
|
showErrorToast(message);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
<LuKey className="size-4" />
|
|
{t("profileInfo.sections.security")}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{profile.password_protected
|
|
? t("profileInfo.security.protected")
|
|
: t("profileInfo.security.unprotected")}
|
|
</p>
|
|
|
|
{profile.password_protected && (
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setVerifyPassword("");
|
|
setIsVerifyOpen(true);
|
|
}}
|
|
className={cn(
|
|
"flex-1 h-7 px-2 text-xs rounded-md border transition-colors",
|
|
"border-border text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
|
)}
|
|
>
|
|
{t("profilePassword.modes.validate")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setMode("change");
|
|
reset();
|
|
}}
|
|
className={cn(
|
|
"flex-1 h-7 px-2 text-xs rounded-md border transition-colors",
|
|
mode === "change"
|
|
? "bg-accent text-accent-foreground border-transparent"
|
|
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
|
)}
|
|
>
|
|
{t("profilePassword.modes.change")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setMode("remove");
|
|
reset();
|
|
}}
|
|
className={cn(
|
|
"flex-1 h-7 px-2 text-xs rounded-md border transition-colors",
|
|
mode === "remove"
|
|
? "bg-destructive/10 text-destructive border-transparent"
|
|
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
|
)}
|
|
>
|
|
{t("profilePassword.modes.remove")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-2">
|
|
{(mode === "change" || mode === "remove") && (
|
|
<Input
|
|
type="password"
|
|
value={oldPassword}
|
|
onChange={(e) => {
|
|
setOldPassword(e.target.value);
|
|
setError(null);
|
|
}}
|
|
placeholder={t("profilePassword.fields.currentPassword")}
|
|
disabled={isRunning || isSubmitting}
|
|
className="h-8 text-xs"
|
|
/>
|
|
)}
|
|
{(mode === "set" || mode === "change") && (
|
|
<>
|
|
<Input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => {
|
|
setPassword(e.target.value);
|
|
setError(null);
|
|
}}
|
|
placeholder={t("profilePassword.fields.newPassword")}
|
|
disabled={isRunning || isSubmitting}
|
|
className="h-8 text-xs"
|
|
/>
|
|
<Input
|
|
type="password"
|
|
value={confirm}
|
|
onChange={(e) => {
|
|
setConfirm(e.target.value);
|
|
setError(null);
|
|
}}
|
|
placeholder={t("profilePassword.fields.confirmPassword")}
|
|
disabled={isRunning || isSubmitting}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
{success && !error && <p className="text-xs text-success">{success}</p>}
|
|
|
|
{isRunning && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("profileInfo.security.cannotWhileRunning")}
|
|
</p>
|
|
)}
|
|
|
|
<Button
|
|
size="sm"
|
|
variant={mode === "remove" ? "destructive" : "default"}
|
|
className="self-start h-7 text-xs"
|
|
disabled={isRunning || isSubmitting}
|
|
onClick={() => {
|
|
void onSubmit();
|
|
}}
|
|
>
|
|
{mode === "set"
|
|
? t("profilePassword.modes.set")
|
|
: mode === "change"
|
|
? t("profilePassword.modes.change")
|
|
: t("profilePassword.modes.remove")}
|
|
</Button>
|
|
|
|
<Dialog
|
|
open={isVerifyOpen}
|
|
onOpenChange={(open) => {
|
|
if (!isVerifying) {
|
|
setIsVerifyOpen(open);
|
|
if (!open) setVerifyPassword("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("profilePassword.verifyDialog.title")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("profilePassword.verifyDialog.description")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<Input
|
|
type="password"
|
|
placeholder={t("profilePassword.fields.currentPassword")}
|
|
value={verifyPassword}
|
|
autoFocus
|
|
onChange={(e) => setVerifyPassword(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && verifyPassword.length > 0) {
|
|
e.preventDefault();
|
|
void onVerify();
|
|
}
|
|
}}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
disabled={isVerifying}
|
|
onClick={() => {
|
|
setIsVerifyOpen(false);
|
|
setVerifyPassword("");
|
|
}}
|
|
>
|
|
{t("common.buttons.cancel")}
|
|
</Button>
|
|
<Button
|
|
disabled={isVerifying || verifyPassword.length === 0}
|
|
onClick={() => void onVerify()}
|
|
>
|
|
{isVerifying
|
|
? t("common.buttons.loading")
|
|
: t("profilePassword.verifyDialog.submit")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ProfileLaunchHookDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
profileId: string | null;
|
|
currentLaunchHook: string | null;
|
|
}
|
|
|
|
export function ProfileLaunchHookDialog({
|
|
isOpen,
|
|
onClose,
|
|
profileId,
|
|
currentLaunchHook,
|
|
}: ProfileLaunchHookDialogProps) {
|
|
const { t } = useTranslation();
|
|
const [value, setValue] = React.useState(currentLaunchHook ?? "");
|
|
const [isSaving, setIsSaving] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
if (isOpen) {
|
|
setValue(currentLaunchHook ?? "");
|
|
}
|
|
}, [isOpen, currentLaunchHook]);
|
|
|
|
const trimmed = value.trim();
|
|
const saved = currentLaunchHook ?? "";
|
|
const isDirty = trimmed !== saved;
|
|
|
|
const handleSave = async () => {
|
|
if (!profileId) return;
|
|
setIsSaving(true);
|
|
try {
|
|
await invoke("update_profile_launch_hook", {
|
|
profileId,
|
|
launchHook: trimmed || null,
|
|
});
|
|
onClose();
|
|
} catch (err) {
|
|
console.error("Failed to update launch hook:", err);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("profileInfo.launchHook.title")}</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("profileInfo.launchHook.description")}
|
|
</p>
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => {
|
|
setValue(e.target.value);
|
|
}}
|
|
placeholder={t("profileInfo.launchHook.placeholder")}
|
|
disabled={isSaving}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
onClick={() => void handleSave()}
|
|
disabled={isSaving || !isDirty}
|
|
className="w-full"
|
|
>
|
|
{t("common.buttons.save")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
interface ProfileDnsBlocklistDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
profileId: string | null;
|
|
currentLevel: string | null;
|
|
}
|
|
|
|
export function ProfileDnsBlocklistDialog({
|
|
isOpen,
|
|
onClose,
|
|
profileId,
|
|
currentLevel,
|
|
}: ProfileDnsBlocklistDialogProps) {
|
|
const { t } = useTranslation();
|
|
const [level, setLevel] = React.useState(currentLevel ?? "");
|
|
const [isSaving, setIsSaving] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
if (isOpen) {
|
|
setLevel(currentLevel ?? "");
|
|
}
|
|
}, [isOpen, currentLevel]);
|
|
|
|
const handleSave = async () => {
|
|
if (!profileId) return;
|
|
setIsSaving(true);
|
|
try {
|
|
await invoke("update_profile_dns_blocklist", {
|
|
profileId,
|
|
dnsBlocklist: level || null,
|
|
});
|
|
onClose();
|
|
} catch (err) {
|
|
console.error("Failed to update DNS blocklist:", err);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const options = [
|
|
{ value: "", label: t("dnsBlocklist.none") },
|
|
{ value: "light", label: t("dnsBlocklist.light") },
|
|
{ value: "normal", label: t("dnsBlocklist.normal") },
|
|
{ value: "pro", label: t("dnsBlocklist.pro") },
|
|
{ value: "pro_plus", label: t("dnsBlocklist.proPlus") },
|
|
{ value: "ultimate", label: t("dnsBlocklist.ultimate") },
|
|
];
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent className="max-w-xs">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("dnsBlocklist.title")}</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("dnsBlocklist.settingsDescription")}{" "}
|
|
<a
|
|
href="https://github.com/hagezi/dns-blocklists"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline"
|
|
>
|
|
{t("common.buttons.moreInfo")}
|
|
</a>
|
|
</p>
|
|
<div className="space-y-1">
|
|
{options.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => setLevel(option.value)}
|
|
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
|
|
level === option.value
|
|
? "bg-primary/10 text-primary border border-primary/30"
|
|
: "hover:bg-accent border border-transparent"
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<Button
|
|
onClick={() => void handleSave()}
|
|
disabled={isSaving || level === (currentLevel ?? "")}
|
|
className="w-full"
|
|
>
|
|
{t("common.buttons.save")}
|
|
</Button>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
interface ProfileBypassRulesDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
profileId: string | null;
|
|
initialRules?: string[];
|
|
}
|
|
|
|
export function ProfileBypassRulesDialog({
|
|
isOpen,
|
|
onClose,
|
|
profileId,
|
|
initialRules,
|
|
}: ProfileBypassRulesDialogProps) {
|
|
const { t } = useTranslation();
|
|
const [bypassRules, setBypassRules] = React.useState<string[]>([]);
|
|
const [newRule, setNewRule] = React.useState("");
|
|
|
|
React.useEffect(() => {
|
|
if (isOpen) {
|
|
setBypassRules(initialRules ?? []);
|
|
setNewRule("");
|
|
}
|
|
}, [isOpen, initialRules]);
|
|
|
|
const updateBypassRules = async (rules: string[]) => {
|
|
if (!profileId) return;
|
|
try {
|
|
await invoke("update_profile_proxy_bypass_rules", {
|
|
profileId,
|
|
rules,
|
|
});
|
|
setBypassRules(rules);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const handleAddRule = () => {
|
|
const trimmed = newRule.trim();
|
|
if (!trimmed || bypassRules.includes(trimmed)) return;
|
|
const updated = [...bypassRules, trimmed];
|
|
setNewRule("");
|
|
void updateBypassRules(updated);
|
|
};
|
|
|
|
const handleRemoveRule = (rule: string) => {
|
|
void updateBypassRules(bypassRules.filter((r) => r !== rule));
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
open={isOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) onClose();
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col">
|
|
<DialogHeader className="shrink-0">
|
|
<DialogTitle>{t("profileInfo.network.bypassRulesTitle")}</DialogTitle>
|
|
</DialogHeader>
|
|
<ScrollArea className="flex-1 min-h-0">
|
|
<div className="flex flex-col gap-3 py-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("profileInfo.network.bypassRulesDescription")}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={newRule}
|
|
onChange={(e) => {
|
|
setNewRule(e.target.value);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleAddRule();
|
|
}}
|
|
placeholder={t("profileInfo.network.rulePlaceholder")}
|
|
className="flex-1 text-sm"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleAddRule}
|
|
disabled={!newRule.trim()}
|
|
>
|
|
<LuPlus className="size-4 mr-1" />
|
|
{t("profileInfo.network.addRule")}
|
|
</Button>
|
|
</div>
|
|
{bypassRules.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-2">
|
|
{t("profileInfo.network.noRules")}
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-1.5">
|
|
{bypassRules.map((rule) => (
|
|
<div
|
|
key={rule}
|
|
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
|
|
>
|
|
<span className="font-mono text-xs truncate">{rule}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
handleRemoveRule(rule);
|
|
}}
|
|
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
|
>
|
|
<LuX className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("profileInfo.network.ruleTypes")}
|
|
</p>
|
|
</div>
|
|
</ScrollArea>
|
|
<DialogFooter className="shrink-0">
|
|
<Button variant="outline" onClick={onClose}>
|
|
{t("common.buttons.close")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|