mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-22 20:06:18 +02:00
feat: profile settings refresh
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use crate::browser::{create_browser, BrowserType, ProxySettings};
|
||||
use crate::camoufox_manager::{CamoufoxConfig, CamoufoxManager};
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::events;
|
||||
use crate::platform_browser;
|
||||
@@ -37,6 +38,17 @@ impl BrowserRunner {
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
|
||||
/// then resolve the proxy settings.
|
||||
async fn resolve_proxy_with_refresh(&self, proxy_id: Option<&String>) -> Option<ProxySettings> {
|
||||
let proxy_id = proxy_id?;
|
||||
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
|
||||
log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}");
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
}
|
||||
PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)
|
||||
}
|
||||
|
||||
/// Get the executable path for a browser profile
|
||||
/// This is a common helper to eliminate code duplication across the codebase
|
||||
pub fn get_browser_executable_path(
|
||||
@@ -92,10 +104,10 @@ impl BrowserRunner {
|
||||
});
|
||||
|
||||
// Always start a local proxy for Camoufox (for traffic monitoring and geoip support)
|
||||
let mut upstream_proxy = profile
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let mut upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
|
||||
.await;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
@@ -332,10 +344,10 @@ impl BrowserRunner {
|
||||
});
|
||||
|
||||
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
|
||||
let mut upstream_proxy = profile
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let mut upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
|
||||
.await;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
@@ -567,11 +579,10 @@ impl BrowserRunner {
|
||||
// Continue anyway, the error might not be critical
|
||||
}
|
||||
|
||||
// Get stored proxy settings for later use (removed as we handle this in proxy startup)
|
||||
let _stored_proxy_settings = profile
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let _stored_proxy_settings = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
|
||||
.await;
|
||||
|
||||
// Use provided local proxy for Chromium-based browsers launch arguments
|
||||
let proxy_for_launch_args: Option<&ProxySettings> = local_proxy_settings;
|
||||
|
||||
@@ -37,6 +37,8 @@ pub struct CloudUser {
|
||||
pub proxy_bandwidth_limit_mb: i64,
|
||||
#[serde(rename = "proxyBandwidthUsedMb")]
|
||||
pub proxy_bandwidth_used_mb: i64,
|
||||
#[serde(rename = "proxyBandwidthExtraMb", default)]
|
||||
pub proxy_bandwidth_extra_mb: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::api_client::is_browser_version_nightly;
|
||||
use crate::browser::{create_browser, BrowserType, ProxySettings};
|
||||
use crate::camoufox_manager::CamoufoxConfig;
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::events;
|
||||
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
|
||||
@@ -53,6 +54,15 @@ impl ProfileManager {
|
||||
if proxy_id.is_some() && vpn_id.is_some() {
|
||||
return Err("Cannot set both proxy_id and vpn_id".into());
|
||||
}
|
||||
|
||||
// Sync cloud proxy credentials if the profile uses a cloud or cloud-derived proxy
|
||||
if let Some(ref pid) = proxy_id {
|
||||
if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
|
||||
log::info!("Syncing cloud proxy credentials before profile creation");
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Attempting to create profile: {name}");
|
||||
|
||||
// Check if a profile with this name already exists (case insensitive)
|
||||
|
||||
@@ -769,6 +769,14 @@ impl ProxyManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Check if a proxy is cloud-managed or cloud-derived (needs fresh credentials)
|
||||
pub fn is_cloud_or_derived(&self, proxy_id: &str) -> bool {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies
|
||||
.get(proxy_id)
|
||||
.is_some_and(|p| p.is_cloud_managed || p.is_cloud_derived)
|
||||
}
|
||||
|
||||
// Get proxy settings for a stored proxy ID
|
||||
pub fn get_proxy_settings_by_id(&self, proxy_id: &str) -> Option<ProxySettings> {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
|
||||
@@ -362,20 +362,15 @@ impl VersionUpdater {
|
||||
eprintln!("Failed to emit completion progress: {e}");
|
||||
}
|
||||
|
||||
// After all version updates are complete, trigger auto-update check
|
||||
if total_new_versions > 0 {
|
||||
println!(
|
||||
"Found {total_new_versions} new versions across all browsers. Checking for auto-updates..."
|
||||
);
|
||||
|
||||
// Trigger auto-update check which will automatically download browsers
|
||||
self
|
||||
.auto_updater
|
||||
.check_for_updates_with_progress(app_handle)
|
||||
.await;
|
||||
} else {
|
||||
println!("No new versions found, skipping auto-update check");
|
||||
}
|
||||
// Always check for auto-updates — profiles may still be on older versions
|
||||
// even if no new versions were found in the cache this cycle
|
||||
println!(
|
||||
"Checking for browser auto-updates (found {total_new_versions} new versions in cache)..."
|
||||
);
|
||||
self
|
||||
.auto_updater
|
||||
.check_for_updates_with_progress(app_handle)
|
||||
.await;
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
@@ -16,16 +16,17 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { IoEllipsisHorizontal } from "react-icons/io5";
|
||||
import {
|
||||
LuCheck,
|
||||
LuChevronDown,
|
||||
LuChevronUp,
|
||||
LuCookie,
|
||||
LuInfo,
|
||||
LuTrash2,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { ProfileInfoDialog } from "@/components/profile-info-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -37,18 +38,11 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
@@ -868,6 +862,8 @@ export function ProfilesDataTable({
|
||||
const [profileToDelete, setProfileToDelete] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [profileForInfoDialog, setProfileForInfoDialog] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -2292,123 +2288,18 @@ export function ProfilesDataTable({
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isCrossOs;
|
||||
const isDeleteDisabled = isRunning || isLaunching || isStopping;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 w-8 h-8"
|
||||
disabled={!meta.isClient}
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<IoEllipsisHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onOpenTrafficDialog?.(profile.id);
|
||||
}}
|
||||
disabled={isCrossOs}
|
||||
>
|
||||
{meta.t("profiles.actions.viewNetwork")}
|
||||
</DropdownMenuItem>
|
||||
{!profile.ephemeral && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onOpenProfileSyncDialog?.(profile);
|
||||
}}
|
||||
disabled={isCrossOs}
|
||||
>
|
||||
{meta.t("profiles.actions.syncSettings")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onAssignProfilesToGroup?.([profile.id]);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{meta.t("profiles.actions.assignToGroup")}
|
||||
</DropdownMenuItem>
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
meta.onConfigureCamoufox && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onConfigureCamoufox?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{meta.t("profiles.actions.changeFingerprint")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
!profile.ephemeral &&
|
||||
meta.onCopyCookiesToProfile && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onCopyCookiesToProfile?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled || !meta.crossOsUnlocked}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{meta.t("profiles.actions.copyCookiesToProfile")}
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
!profile.ephemeral &&
|
||||
meta.onOpenCookieManagement && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onOpenCookieManagement?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled || !meta.crossOsUnlocked}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{meta.t("cookies.management.menuItem")}
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!profile.ephemeral && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onCloneProfile?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{meta.t("profiles.actions.clone")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setProfileToDelete(profile);
|
||||
}}
|
||||
disabled={isDeleteDisabled}
|
||||
>
|
||||
{meta.t("profiles.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 w-8 h-8"
|
||||
disabled={!meta.isClient}
|
||||
onClick={() => setProfileForInfoDialog(profile)}
|
||||
>
|
||||
<span className="sr-only">Profile info</span>
|
||||
<LuInfo className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -2512,6 +2403,45 @@ export function ProfilesDataTable({
|
||||
confirmButtonText="Delete Profile"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
{profileForInfoDialog &&
|
||||
(() => {
|
||||
const infoProfile = profileForInfoDialog;
|
||||
const infoIsRunning =
|
||||
browserState.isClient && runningProfiles.has(infoProfile.id);
|
||||
const infoIsLaunching = launchingProfiles.has(infoProfile.id);
|
||||
const infoIsStopping = stoppingProfiles.has(infoProfile.id);
|
||||
const infoIsCrossOs = isCrossOsProfile(infoProfile);
|
||||
const infoIsDisabled =
|
||||
infoIsRunning || infoIsLaunching || infoIsStopping || infoIsCrossOs;
|
||||
return (
|
||||
<ProfileInfoDialog
|
||||
isOpen={profileForInfoDialog !== null}
|
||||
onClose={() => setProfileForInfoDialog(null)}
|
||||
profile={infoProfile}
|
||||
storedProxies={storedProxies}
|
||||
vpnConfigs={vpnConfigs}
|
||||
onOpenTrafficDialog={(profileId) => {
|
||||
const profile = profiles.find((p) => p.id === profileId);
|
||||
setTrafficDialogProfile({ id: profileId, name: profile?.name });
|
||||
}}
|
||||
onOpenProfileSyncDialog={onOpenProfileSyncDialog}
|
||||
onAssignProfilesToGroup={onAssignProfilesToGroup}
|
||||
onConfigureCamoufox={onConfigureCamoufox}
|
||||
onCopyCookiesToProfile={onCopyCookiesToProfile}
|
||||
onOpenCookieManagement={onOpenCookieManagement}
|
||||
onCloneProfile={onCloneProfile}
|
||||
onDeleteProfile={(profile) => {
|
||||
setProfileForInfoDialog(null);
|
||||
setProfileToDelete(profile);
|
||||
}}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
isRunning={infoIsRunning}
|
||||
isDisabled={infoIsDisabled}
|
||||
isCrossOs={infoIsCrossOs}
|
||||
syncStatuses={syncStatuses}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<DataTableActionBar table={table}>
|
||||
<DataTableActionBarSelection table={table} />
|
||||
{onBulkGroupAssignment && (
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
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,
|
||||
LuRefreshCw,
|
||||
LuSettings,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
getBrowserDisplayName,
|
||||
getOSDisplayName,
|
||||
getProfileIcon,
|
||||
isCrossOsProfile,
|
||||
} from "@/lib/browser-utils";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
ProfileGroup,
|
||||
StoredProxy,
|
||||
VpnConfig,
|
||||
} 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;
|
||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||
onDeleteProfile?: (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="w-3.5 h-3.5" />;
|
||||
case "windows":
|
||||
return <FaWindows className="w-3.5 h-3.5" />;
|
||||
case "linux":
|
||||
return <FaLinux className="w-3.5 h-3.5" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProfileInfoDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
storedProxies,
|
||||
vpnConfigs,
|
||||
onOpenTrafficDialog,
|
||||
onOpenProfileSyncDialog,
|
||||
onAssignProfilesToGroup,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onOpenCookieManagement,
|
||||
onCloneProfile,
|
||||
onDeleteProfile,
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !profile?.group_id) {
|
||||
setGroupName(null);
|
||||
return;
|
||||
}
|
||||
(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) {
|
||||
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 syncLabel = syncStatus
|
||||
? `${syncMode} (${syncStatus.status})`
|
||||
: syncMode;
|
||||
|
||||
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 infoFields: { label: string; value: React.ReactNode }[] = [
|
||||
{
|
||||
label: t("profileInfo.fields.profileId"),
|
||||
value: (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-xs truncate max-w-[180px]">
|
||||
{profile.id}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopyId()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<LuClipboardCheck className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<LuClipboard className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t("profileInfo.fields.browser"),
|
||||
value: `${getBrowserDisplayName(profile.browser)} ${profile.version}`,
|
||||
},
|
||||
{
|
||||
label: t("profileInfo.fields.releaseType"),
|
||||
value:
|
||||
profile.release_type.charAt(0).toUpperCase() +
|
||||
profile.release_type.slice(1),
|
||||
},
|
||||
{
|
||||
label: t("profileInfo.fields.proxyVpn"),
|
||||
value: networkLabel,
|
||||
},
|
||||
{
|
||||
label: t("profileInfo.fields.group"),
|
||||
value: groupName ?? t("profileInfo.values.none"),
|
||||
},
|
||||
{
|
||||
label: t("profileInfo.fields.tags"),
|
||||
value:
|
||||
profile.tags && profile.tags.length > 0 ? (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{profile.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
t("profileInfo.values.none")
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t("profileInfo.fields.note"),
|
||||
value: profile.note || t("profileInfo.values.none"),
|
||||
},
|
||||
{
|
||||
label: t("profileInfo.fields.syncStatus"),
|
||||
value: syncLabel,
|
||||
},
|
||||
{
|
||||
label: t("profileInfo.fields.lastLaunched"),
|
||||
value: profile.last_launch
|
||||
? formatRelativeTime(profile.last_launch)
|
||||
: t("profileInfo.values.never"),
|
||||
},
|
||||
];
|
||||
|
||||
if (profile.host_os && isCrossOsProfile(profile)) {
|
||||
infoFields.push({
|
||||
label: t("profileInfo.fields.hostOs"),
|
||||
value: (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<OSIcon os={profile.host_os} />
|
||||
{getOSDisplayName(profile.host_os)}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (profile.ephemeral) {
|
||||
infoFields.push({
|
||||
label: t("profileInfo.fields.ephemeral"),
|
||||
value: (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("profileInfo.values.yes")}
|
||||
</Badge>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
type ActionItem = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
proBadge?: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
const actions: ActionItem[] = [
|
||||
{
|
||||
icon: <LuGlobe className="w-4 h-4" />,
|
||||
label: t("profiles.actions.viewNetwork"),
|
||||
onClick: () => handleAction(() => onOpenTrafficDialog?.(profile.id)),
|
||||
disabled: isCrossOs,
|
||||
},
|
||||
{
|
||||
icon: <LuRefreshCw className="w-4 h-4" />,
|
||||
label: t("profiles.actions.syncSettings"),
|
||||
onClick: () => handleAction(() => onOpenProfileSyncDialog?.(profile)),
|
||||
disabled: isCrossOs,
|
||||
hidden: profile.ephemeral === true,
|
||||
},
|
||||
{
|
||||
icon: <LuGroup className="w-4 h-4" />,
|
||||
label: t("profiles.actions.assignToGroup"),
|
||||
onClick: () =>
|
||||
handleAction(() => onAssignProfilesToGroup?.([profile.id])),
|
||||
disabled: isDisabled,
|
||||
},
|
||||
{
|
||||
icon: <LuFingerprint className="w-4 h-4" />,
|
||||
label: t("profiles.actions.changeFingerprint"),
|
||||
onClick: () => handleAction(() => onConfigureCamoufox?.(profile)),
|
||||
disabled: isDisabled,
|
||||
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
|
||||
},
|
||||
{
|
||||
icon: <LuCopy className="w-4 h-4" />,
|
||||
label: t("profiles.actions.copyCookiesToProfile"),
|
||||
onClick: () => handleAction(() => onCopyCookiesToProfile?.(profile)),
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
hidden:
|
||||
!isCamoufoxOrWayfern ||
|
||||
profile.ephemeral === true ||
|
||||
!onCopyCookiesToProfile,
|
||||
},
|
||||
{
|
||||
icon: <LuCookie className="w-4 h-4" />,
|
||||
label: t("profileInfo.actions.manageCookies"),
|
||||
onClick: () => handleAction(() => onOpenCookieManagement?.(profile)),
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
hidden:
|
||||
!isCamoufoxOrWayfern ||
|
||||
profile.ephemeral === true ||
|
||||
!onOpenCookieManagement,
|
||||
},
|
||||
{
|
||||
icon: <LuSettings className="w-4 h-4" />,
|
||||
label: t("profiles.actions.clone"),
|
||||
onClick: () => handleAction(() => onCloneProfile?.(profile)),
|
||||
disabled: isDisabled,
|
||||
hidden: profile.ephemeral === true,
|
||||
},
|
||||
{
|
||||
icon: <LuTrash2 className="w-4 h-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) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ProfileIcon className="w-5 h-5" />
|
||||
{profile.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="info">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
{t("profileInfo.tabs.info")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
{t("profileInfo.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="info">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 py-2">
|
||||
{infoFields.map((field) => (
|
||||
<React.Fragment key={field.label}>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{field.label}
|
||||
</span>
|
||||
<span className="text-sm">{field.value}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<div className="flex flex-col py-1">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
|
||||
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
|
||||
action.destructive &&
|
||||
"text-destructive hover:bg-destructive/10",
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
<span className="flex-1 flex items-center gap-2">
|
||||
{action.label}
|
||||
{action.proBadge && <ProBadge />}
|
||||
</span>
|
||||
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.buttons.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -292,11 +292,23 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Proxy Bandwidth</span>
|
||||
<span>
|
||||
{user.proxyBandwidthUsedMb} / {user.proxyBandwidthLimitMb}{" "}
|
||||
{user.proxyBandwidthUsedMb} /{" "}
|
||||
{user.proxyBandwidthLimitMb +
|
||||
(user.proxyBandwidthExtraMb || 0)}{" "}
|
||||
MB
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(user.proxyBandwidthExtraMb || 0) > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Extra Bandwidth</span>
|
||||
<span>
|
||||
{user.proxyBandwidthExtraMb >= 1000
|
||||
? `${(user.proxyBandwidthExtraMb / 1000).toFixed(1)} GB`
|
||||
: `${user.proxyBandwidthExtraMb} MB`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
|
||||
@@ -678,6 +678,35 @@
|
||||
"error": "Failed to export cookies"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "Profile Details",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "Profile ID",
|
||||
"browser": "Browser",
|
||||
"releaseType": "Release Type",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"group": "Group",
|
||||
"tags": "Tags",
|
||||
"note": "Note",
|
||||
"syncStatus": "Sync Status",
|
||||
"lastLaunched": "Last Launched",
|
||||
"hostOs": "Host OS",
|
||||
"ephemeral": "Ephemeral"
|
||||
},
|
||||
"values": {
|
||||
"none": "None",
|
||||
"never": "Never",
|
||||
"copied": "Copied!",
|
||||
"yes": "Yes"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Manage Cookies"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "Fingerprint editing is a Pro feature",
|
||||
|
||||
@@ -678,6 +678,35 @@
|
||||
"error": "Error al exportar cookies"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "Detalles del Perfil",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "ID del Perfil",
|
||||
"browser": "Navegador",
|
||||
"releaseType": "Tipo de Versión",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"group": "Grupo",
|
||||
"tags": "Etiquetas",
|
||||
"note": "Nota",
|
||||
"syncStatus": "Estado de Sincronización",
|
||||
"lastLaunched": "Último Lanzamiento",
|
||||
"hostOs": "SO Host",
|
||||
"ephemeral": "Efímero"
|
||||
},
|
||||
"values": {
|
||||
"none": "Ninguno",
|
||||
"never": "Nunca",
|
||||
"copied": "¡Copiado!",
|
||||
"yes": "Sí"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Administrar Cookies"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "La edición de huellas digitales es una función Pro",
|
||||
|
||||
@@ -678,6 +678,35 @@
|
||||
"error": "Échec de l'exportation des cookies"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "Détails du Profil",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "ID du Profil",
|
||||
"browser": "Navigateur",
|
||||
"releaseType": "Type de Version",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"group": "Groupe",
|
||||
"tags": "Tags",
|
||||
"note": "Note",
|
||||
"syncStatus": "État de Synchronisation",
|
||||
"lastLaunched": "Dernier Lancement",
|
||||
"hostOs": "OS Hôte",
|
||||
"ephemeral": "Éphémère"
|
||||
},
|
||||
"values": {
|
||||
"none": "Aucun",
|
||||
"never": "Jamais",
|
||||
"copied": "Copié !",
|
||||
"yes": "Oui"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Gérer les Cookies"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "La modification d'empreinte est une fonctionnalité Pro",
|
||||
|
||||
@@ -678,6 +678,35 @@
|
||||
"error": "Cookieのエクスポートに失敗しました"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "プロフィール詳細",
|
||||
"tabs": {
|
||||
"info": "情報",
|
||||
"settings": "設定"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "プロフィールID",
|
||||
"browser": "ブラウザ",
|
||||
"releaseType": "リリースタイプ",
|
||||
"proxyVpn": "プロキシ / VPN",
|
||||
"group": "グループ",
|
||||
"tags": "タグ",
|
||||
"note": "メモ",
|
||||
"syncStatus": "同期ステータス",
|
||||
"lastLaunched": "最終起動",
|
||||
"hostOs": "ホストOS",
|
||||
"ephemeral": "エフェメラル"
|
||||
},
|
||||
"values": {
|
||||
"none": "なし",
|
||||
"never": "なし",
|
||||
"copied": "コピーしました!",
|
||||
"yes": "はい"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Cookieを管理"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "フィンガープリント編集はプロ機能です",
|
||||
|
||||
@@ -678,6 +678,35 @@
|
||||
"error": "Falha ao exportar cookies"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "Detalhes do Perfil",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"settings": "Configurações"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "ID do Perfil",
|
||||
"browser": "Navegador",
|
||||
"releaseType": "Tipo de Versão",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"group": "Grupo",
|
||||
"tags": "Tags",
|
||||
"note": "Nota",
|
||||
"syncStatus": "Status de Sincronização",
|
||||
"lastLaunched": "Último Lançamento",
|
||||
"hostOs": "SO Host",
|
||||
"ephemeral": "Efêmero"
|
||||
},
|
||||
"values": {
|
||||
"none": "Nenhum",
|
||||
"never": "Nunca",
|
||||
"copied": "Copiado!",
|
||||
"yes": "Sim"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Gerenciar Cookies"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "A edição de impressão digital é um recurso Pro",
|
||||
|
||||
@@ -678,6 +678,35 @@
|
||||
"error": "Ошибка экспорта cookies"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "Детали профиля",
|
||||
"tabs": {
|
||||
"info": "Информация",
|
||||
"settings": "Настройки"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "ID профиля",
|
||||
"browser": "Браузер",
|
||||
"releaseType": "Тип релиза",
|
||||
"proxyVpn": "Прокси / VPN",
|
||||
"group": "Группа",
|
||||
"tags": "Теги",
|
||||
"note": "Заметка",
|
||||
"syncStatus": "Статус синхронизации",
|
||||
"lastLaunched": "Последний запуск",
|
||||
"hostOs": "ОС хоста",
|
||||
"ephemeral": "Эфемерный"
|
||||
},
|
||||
"values": {
|
||||
"none": "Нет",
|
||||
"never": "Никогда",
|
||||
"copied": "Скопировано!",
|
||||
"yes": "Да"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Управление Cookie"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "Редактирование отпечатка — функция Pro",
|
||||
|
||||
@@ -678,6 +678,35 @@
|
||||
"error": "导出 Cookies 失败"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "配置文件详情",
|
||||
"tabs": {
|
||||
"info": "信息",
|
||||
"settings": "设置"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "配置文件 ID",
|
||||
"browser": "浏览器",
|
||||
"releaseType": "发布类型",
|
||||
"proxyVpn": "代理 / VPN",
|
||||
"group": "分组",
|
||||
"tags": "标签",
|
||||
"note": "备注",
|
||||
"syncStatus": "同步状态",
|
||||
"lastLaunched": "上次启动",
|
||||
"hostOs": "主机操作系统",
|
||||
"ephemeral": "临时"
|
||||
},
|
||||
"values": {
|
||||
"none": "无",
|
||||
"never": "从未",
|
||||
"copied": "已复制!",
|
||||
"yes": "是"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "管理 Cookie"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "指纹编辑是 Pro 功能",
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface CloudUser {
|
||||
cloudProfilesUsed: number;
|
||||
proxyBandwidthLimitMb: number;
|
||||
proxyBandwidthUsedMb: number;
|
||||
proxyBandwidthExtraMb: number;
|
||||
}
|
||||
|
||||
export interface CloudAuthState {
|
||||
|
||||
Reference in New Issue
Block a user