feat: extension management

This commit is contained in:
zhom
2026-03-02 07:26:42 +04:00
parent a723c8b30b
commit 8a96d18e46
36 changed files with 3915 additions and 86 deletions
+39 -14
View File
@@ -10,6 +10,7 @@ import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
import { GroupBadges } from "@/components/group-badges";
import { GroupManagementDialog } from "@/components/group-management-dialog";
@@ -139,6 +140,8 @@ export default function Home() {
useState(false);
const [groupManagementDialogOpen, setGroupManagementDialogOpen] =
useState(false);
const [extensionManagementDialogOpen, setExtensionManagementDialogOpen] =
useState(false);
const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] =
useState(false);
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
@@ -500,23 +503,38 @@ export default function Home() {
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig;
groupId?: string;
extensionGroupId?: string;
ephemeral?: boolean;
}) => {
try {
await invoke<BrowserProfile>("create_browser_profile_new", {
name: profileData.name,
browserStr: profileData.browserStr,
version: profileData.version,
releaseType: profileData.releaseType,
proxyId: profileData.proxyId,
vpnId: profileData.vpnId,
camoufoxConfig: profileData.camoufoxConfig,
wayfernConfig: profileData.wayfernConfig,
groupId:
profileData.groupId ||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
ephemeral: profileData.ephemeral,
});
const profile = await invoke<BrowserProfile>(
"create_browser_profile_new",
{
name: profileData.name,
browserStr: profileData.browserStr,
version: profileData.version,
releaseType: profileData.releaseType,
proxyId: profileData.proxyId,
vpnId: profileData.vpnId,
camoufoxConfig: profileData.camoufoxConfig,
wayfernConfig: profileData.wayfernConfig,
groupId:
profileData.groupId ||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
ephemeral: profileData.ephemeral,
},
);
if (profileData.extensionGroupId) {
try {
await invoke("assign_extension_group_to_profile", {
profileId: profile.id,
extensionGroupId: profileData.extensionGroupId,
});
} catch (err) {
console.error("Failed to assign extension group:", err);
}
}
// No need to manually reload - useProfileEvents will handle the update
} catch (error) {
@@ -1014,6 +1032,7 @@ export default function Home() {
onSettingsDialogOpen={setSettingsDialogOpen}
onSyncConfigDialogOpen={setSyncConfigDialogOpen}
onIntegrationsDialogOpen={setIntegrationsDialogOpen}
onExtensionManagementDialogOpen={setExtensionManagementDialogOpen}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
/>
@@ -1144,6 +1163,12 @@ export default function Home() {
onGroupManagementComplete={handleGroupManagementComplete}
/>
<ExtensionManagementDialog
isOpen={extensionManagementDialogOpen}
onClose={() => setExtensionManagementDialogOpen(false)}
limitedMode={!crossOsUnlocked}
/>
<GroupAssignmentDialog
isOpen={groupAssignmentDialogOpen}
onClose={() => {
+49
View File
@@ -74,6 +74,7 @@ interface CreateProfileDialogProps {
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig;
groupId?: string;
extensionGroupId?: string;
ephemeral?: boolean;
}) => Promise<void>;
selectedGroupId?: string;
@@ -166,6 +167,21 @@ export function CreateProfileDialog({
const [showProxyForm, setShowProxyForm] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [selectedExtensionGroupId, setSelectedExtensionGroupId] =
useState<string>();
const [extensionGroups, setExtensionGroups] = useState<
{ id: string; name: string; extension_ids: string[] }[]
>([]);
useEffect(() => {
if (isOpen) {
invoke<{ id: string; name: string; extension_ids: string[] }[]>(
"list_extension_groups",
)
.then(setExtensionGroups)
.catch(() => setExtensionGroups([]));
}
}, [isOpen]);
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
const [releaseTypesError, setReleaseTypesError] = useState<string | null>(
@@ -406,6 +422,7 @@ export function CreateProfileDialog({
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
});
} else {
@@ -430,6 +447,7 @@ export function CreateProfileDialog({
camoufoxConfig: finalCamoufoxConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
});
}
@@ -1074,6 +1092,37 @@ export function CreateProfileDialog({
</div>
)}
</div>
{/* Extension Group */}
{extensionGroups.length > 0 && (
<div className="space-y-2">
<Label>{t("extensions.extensionGroup")}</Label>
<Select
value={selectedExtensionGroupId || "none"}
onValueChange={(val) =>
setSelectedExtensionGroupId(
val === "none" ? undefined : val,
)
}
>
<SelectTrigger>
<SelectValue
placeholder={t("profileInfo.values.none")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("profileInfo.values.none")}
</SelectItem>
{extensionGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name} ({g.extension_ids.length})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</TabsContent>
@@ -0,0 +1,716 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuPencil, LuPuzzle, LuTrash2, LuUpload } from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ProBadge } from "@/components/ui/pro-badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { Extension, ExtensionGroup } from "@/types";
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
import { RippleButton } from "./ui/ripple";
interface ExtensionManagementDialogProps {
isOpen: boolean;
onClose: () => void;
limitedMode: boolean;
}
export function ExtensionManagementDialog({
isOpen,
onClose,
limitedMode,
}: ExtensionManagementDialogProps) {
const { t } = useTranslation();
const [extensions, setExtensions] = useState<Extension[]>([]);
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Extension upload state
const [isUploading, setIsUploading] = useState(false);
const [extensionName, setExtensionName] = useState("");
const [showUploadForm, setShowUploadForm] = useState(false);
const [pendingFile, setPendingFile] = useState<{
name: string;
data: number[];
} | null>(null);
// Group state
const [showCreateGroup, setShowCreateGroup] = useState(false);
const [newGroupName, setNewGroupName] = useState("");
const [editingGroup, setEditingGroup] = useState<ExtensionGroup | null>(null);
const [editGroupName, setEditGroupName] = useState("");
// Delete state
const [extensionToDelete, setExtensionToDelete] = useState<Extension | null>(
null,
);
const [groupToDelete, setGroupToDelete] = useState<ExtensionGroup | null>(
null,
);
const [isDeleting, setIsDeleting] = useState(false);
// Tab
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
"extensions",
);
const loadData = useCallback(async () => {
if (limitedMode) return;
setIsLoading(true);
try {
const [exts, groups] = await Promise.all([
invoke<Extension[]>("list_extensions"),
invoke<ExtensionGroup[]>("list_extension_groups"),
]);
setExtensions(exts);
setExtensionGroups(groups);
} catch {
// User may not have pro subscription
setExtensions([]);
setExtensionGroups([]);
} finally {
setIsLoading(false);
}
}, [limitedMode]);
useEffect(() => {
if (isOpen) {
void loadData();
}
}, [isOpen, loadData]);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const validExtensions = [".xpi", ".crx", ".zip"];
const isValid = validExtensions.some((ext) =>
file.name.toLowerCase().endsWith(ext),
);
if (!isValid) {
showErrorToast(t("extensions.invalidFileType"));
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const arrayBuffer = event.target?.result as ArrayBuffer;
const data = Array.from(new Uint8Array(arrayBuffer));
const baseName = file.name
.replace(/\.(xpi|crx|zip)$/i, "")
.replace(/[-_]/g, " ");
setExtensionName(baseName);
setPendingFile({ name: file.name, data });
setShowUploadForm(true);
};
reader.onerror = () => {
showErrorToast(t("extensions.readError"));
};
reader.readAsArrayBuffer(file);
// Reset input
e.target.value = "";
},
[t],
);
const handleUpload = useCallback(async () => {
if (!pendingFile || !extensionName.trim()) return;
setIsUploading(true);
try {
await invoke("add_extension", {
name: extensionName.trim(),
fileName: pendingFile.name,
fileData: pendingFile.data,
});
showSuccessToast(t("extensions.uploadSuccess"));
setShowUploadForm(false);
setPendingFile(null);
setExtensionName("");
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
} finally {
setIsUploading(false);
}
}, [pendingFile, extensionName, loadData, t]);
const handleDeleteExtension = useCallback(async () => {
if (!extensionToDelete) return;
setIsDeleting(true);
try {
await invoke("delete_extension", { extensionId: extensionToDelete.id });
showSuccessToast(t("extensions.deleteSuccess"));
setExtensionToDelete(null);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
} finally {
setIsDeleting(false);
}
}, [extensionToDelete, loadData, t]);
const handleCreateGroup = useCallback(async () => {
if (!newGroupName.trim()) return;
try {
await invoke("create_extension_group", { name: newGroupName.trim() });
showSuccessToast(t("extensions.groupCreateSuccess"));
setShowCreateGroup(false);
setNewGroupName("");
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
}
}, [newGroupName, loadData, t]);
const handleUpdateGroup = useCallback(async () => {
if (!editingGroup || !editGroupName.trim()) return;
try {
await invoke("update_extension_group", {
groupId: editingGroup.id,
name: editGroupName.trim(),
});
showSuccessToast(t("extensions.groupUpdateSuccess"));
setEditingGroup(null);
setEditGroupName("");
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
}
}, [editingGroup, editGroupName, loadData, t]);
const handleDeleteGroup = useCallback(async () => {
if (!groupToDelete) return;
setIsDeleting(true);
try {
await invoke("delete_extension_group", { groupId: groupToDelete.id });
showSuccessToast(t("extensions.groupDeleteSuccess"));
setGroupToDelete(null);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
} finally {
setIsDeleting(false);
}
}, [groupToDelete, loadData, t]);
const handleAddToGroup = useCallback(
async (groupId: string, extensionId: string) => {
try {
await invoke("add_extension_to_group", { groupId, extensionId });
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
}
},
[loadData],
);
const handleRemoveFromGroup = useCallback(
async (groupId: string, extensionId: string) => {
try {
await invoke("remove_extension_from_group", { groupId, extensionId });
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
}
},
[loadData],
);
const getCompatibilityBadge = (compat: string[]) => {
if (compat.includes("chromium") && compat.includes("firefox")) {
return (
<Badge variant="secondary">{t("extensions.compatibility.both")}</Badge>
);
}
if (compat.includes("chromium")) {
return (
<Badge variant="secondary">
{t("extensions.compatibility.chromium")}
</Badge>
);
}
if (compat.includes("firefox")) {
return (
<Badge variant="secondary">
{t("extensions.compatibility.firefox")}
</Badge>
);
}
return null;
};
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuPuzzle className="w-5 h-5" />
{t("extensions.title")}
{limitedMode && <ProBadge />}
</DialogTitle>
<DialogDescription>{t("extensions.description")}</DialogDescription>
</DialogHeader>
<div className="relative">
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
<span className="text-sm font-medium text-muted-foreground">
{t("extensions.proRequired")}
</span>
</div>
</div>
</>
)}
<div className="space-y-4">
{/* Tab selector */}
<div className="flex gap-2 border-b">
<button
type="button"
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "extensions"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTab("extensions")}
disabled={limitedMode}
>
{t("extensions.extensionsTab")}
</button>
<button
type="button"
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "groups"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTab("groups")}
disabled={limitedMode}
>
{t("extensions.groupsTab")}
</button>
</div>
{/* Notice */}
<div className="rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
{t("extensions.managedNotice")}
</div>
{activeTab === "extensions" && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<Label>{t("extensions.extensionsTab")}</Label>
<div>
<label htmlFor="ext-file-input">
<RippleButton
size="sm"
className="flex gap-2 items-center"
disabled={limitedMode}
onClick={() =>
document.getElementById("ext-file-input")?.click()
}
>
<LuUpload className="w-4 h-4" />
{t("extensions.upload")}
</RippleButton>
</label>
<input
id="ext-file-input"
type="file"
accept=".xpi,.crx,.zip"
className="hidden"
onChange={handleFileSelect}
disabled={limitedMode}
/>
</div>
</div>
{/* Upload form */}
{showUploadForm && pendingFile && (
<div className="space-y-3 rounded-md border p-3">
<div className="text-sm text-muted-foreground">
{t("extensions.selectedFile")}:{" "}
<span className="font-medium text-foreground">
{pendingFile.name}
</span>
</div>
<div className="flex gap-2">
<Input
value={extensionName}
onChange={(e) => setExtensionName(e.target.value)}
placeholder={t("extensions.namePlaceholder")}
className="flex-1"
/>
<RippleButton
size="sm"
onClick={handleUpload}
disabled={isUploading || !extensionName.trim()}
>
{isUploading
? t("common.buttons.loading")
: t("common.buttons.add")}
</RippleButton>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowUploadForm(false);
setPendingFile(null);
setExtensionName("");
}}
>
{t("common.buttons.cancel")}
</Button>
</div>
</div>
)}
{/* Extensions list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
{t("common.buttons.loading")}
</div>
) : extensions.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("extensions.empty")}
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[200px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("common.labels.name")}</TableHead>
<TableHead className="w-24">
{t("common.labels.type")}
</TableHead>
<TableHead className="w-32">
{t("extensions.compatibility.label")}
</TableHead>
<TableHead className="w-20">
{t("common.labels.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{extensions.map((ext) => (
<TableRow key={ext.id}>
<TableCell className="font-medium">
{ext.name}
</TableCell>
<TableCell>
<Badge variant="outline">
.{ext.file_type}
</Badge>
</TableCell>
<TableCell>
{getCompatibilityBadge(
ext.browser_compatibility,
)}
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
setExtensionToDelete(ext)
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("extensions.delete")}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
)}
{activeTab === "groups" && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<Label>{t("extensions.groupsTab")}</Label>
<RippleButton
size="sm"
onClick={() => setShowCreateGroup(true)}
className="flex gap-2 items-center"
disabled={limitedMode}
>
<GoPlus className="w-4 h-4" />
{t("extensions.createGroup")}
</RippleButton>
</div>
{/* Create group form */}
{showCreateGroup && (
<div className="flex gap-2 items-center">
<Input
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t("extensions.groupNamePlaceholder")}
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreateGroup();
}}
/>
<RippleButton
size="sm"
onClick={handleCreateGroup}
disabled={!newGroupName.trim()}
>
{t("common.buttons.create")}
</RippleButton>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowCreateGroup(false);
setNewGroupName("");
}}
>
{t("common.buttons.cancel")}
</Button>
</div>
)}
{/* Groups list */}
{extensionGroups.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("extensions.noGroups")}
</div>
) : (
<div className="space-y-3">
{extensionGroups.map((group) => (
<div
key={group.id}
className="rounded-md border p-3 space-y-2"
>
<div className="flex justify-between items-center">
{editingGroup?.id === group.id ? (
<div className="flex gap-2 items-center flex-1">
<Input
value={editGroupName}
onChange={(e) =>
setEditGroupName(e.target.value)
}
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter")
void handleUpdateGroup();
}}
/>
<RippleButton
size="sm"
onClick={handleUpdateGroup}
>
{t("common.buttons.save")}
</RippleButton>
<Button
size="sm"
variant="outline"
onClick={() => setEditingGroup(null)}
>
{t("common.buttons.cancel")}
</Button>
</div>
) : (
<>
<span className="font-medium">
{group.name}
</span>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingGroup(group);
setEditGroupName(group.name);
}}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("common.buttons.edit")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setGroupToDelete(group)}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("extensions.deleteGroup")}
</TooltipContent>
</Tooltip>
</div>
</>
)}
</div>
{/* Extension assignment */}
<div className="space-y-1">
{group.extension_ids.length > 0 && (
<div className="flex flex-wrap gap-1">
{group.extension_ids.map((extId) => {
const ext = extensions.find(
(e) => e.id === extId,
);
if (!ext) return null;
return (
<Badge
key={extId}
variant="secondary"
className="flex items-center gap-1"
>
{ext.name}
<button
type="button"
className="ml-1 hover:text-destructive"
onClick={() =>
handleRemoveFromGroup(group.id, extId)
}
>
×
</button>
</Badge>
);
})}
</div>
)}
{extensions.filter(
(e) => !group.extension_ids.includes(e.id),
).length > 0 && (
<Select
value=""
onValueChange={(extId) =>
handleAddToGroup(group.id, extId)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue
placeholder={t("extensions.addToGroup")}
/>
</SelectTrigger>
<SelectContent>
{extensions
.filter(
(e) =>
!group.extension_ids.includes(e.id),
)
.map((ext) => (
<SelectItem key={ext.id} value={ext.id}>
{ext.name} (.{ext.file_type})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete extension confirmation */}
<DeleteConfirmationDialog
isOpen={extensionToDelete !== null}
onClose={() => setExtensionToDelete(null)}
onConfirm={handleDeleteExtension}
title={t("extensions.deleteConfirmTitle")}
description={t("extensions.deleteConfirmDescription", {
name: extensionToDelete?.name ?? "",
})}
isLoading={isDeleting}
/>
{/* Delete group confirmation */}
<DeleteConfirmationDialog
isOpen={groupToDelete !== null}
onClose={() => setGroupToDelete(null)}
onConfirm={handleDeleteGroup}
title={t("extensions.deleteGroupConfirmTitle")}
description={t("extensions.deleteGroupConfirmDescription", {
name: groupToDelete?.name ?? "",
})}
isLoading={isDeleting}
/>
</>
);
}
+18 -1
View File
@@ -2,7 +2,14 @@ import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import { LuCloud, LuPlug, LuSearch, LuUsers, LuX } from "react-icons/lu";
import {
LuCloud,
LuPlug,
LuPuzzle,
LuSearch,
LuUsers,
LuX,
} from "react-icons/lu";
import { Logo } from "./icons/logo";
import { Button } from "./ui/button";
import { CardTitle } from "./ui/card";
@@ -23,6 +30,7 @@ type Props = {
onCreateProfileDialogOpen: (open: boolean) => void;
onSyncConfigDialogOpen: (open: boolean) => void;
onIntegrationsDialogOpen: (open: boolean) => void;
onExtensionManagementDialogOpen: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
};
@@ -35,6 +43,7 @@ const HomeHeader = ({
onCreateProfileDialogOpen,
onSyncConfigDialogOpen,
onIntegrationsDialogOpen,
onExtensionManagementDialogOpen,
searchQuery,
onSearchQueryChange,
}: Props) => {
@@ -124,6 +133,14 @@ const HomeHeader = ({
<LuUsers className="mr-2 w-4 h-4" />
{t("header.menu.groups")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onExtensionManagementDialogOpen(true);
}}
>
<LuPuzzle className="mr-2 w-4 h-4" />
{t("header.menu.extensions")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onSyncConfigDialogOpen(true);
+33 -30
View File
@@ -1582,6 +1582,7 @@ export function ProfilesDataTable({
const osName = profile.host_os
? getOSDisplayName(profile.host_os)
: "another OS";
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
const OsIcon =
profile.host_os === "macos"
? FaApple
@@ -1606,10 +1607,7 @@ export function ProfilesDataTable({
</span>
</TooltipTrigger>
<TooltipContent>
<p>
This profile was created on {osName} and is not supported on
this system
</p>
<p>{crossOsTooltip}</p>
</TooltipContent>
</Tooltip>
);
@@ -1620,14 +1618,10 @@ export function ProfilesDataTable({
const osName = profile.host_os
? getOSDisplayName(profile.host_os)
: "another OS";
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
return (
<NonHoverableTooltip
content={
<p>
This profile was created on {osName} and is not supported on
this system
</p>
}
content={<p>{crossOsTooltip}</p>}
sideOffset={4}
horizontalOffset={8}
>
@@ -2305,7 +2299,7 @@ export function ProfilesDataTable({
},
},
],
[],
[t],
);
const table = useReactTable({
@@ -2362,25 +2356,34 @@ export function ProfilesDataTable({
</TableHeader>
<TableBody className="overflow-visible">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={cn(
"overflow-visible hover:bg-accent/50",
isCrossOsProfile(row.original) && "opacity-60",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="overflow-visible">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
table.getRowModel().rows.map((row) => {
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(row.original.host_os ?? ""),
})
: undefined;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
title={crossOsTitle}
className={cn(
"overflow-visible hover:bg-accent/50",
rowIsCrossOs && "opacity-60",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="overflow-visible">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow>
<TableCell
+120 -1
View File
@@ -13,9 +13,11 @@ import {
LuFingerprint,
LuGlobe,
LuGroup,
LuPlus,
LuRefreshCw,
LuSettings,
LuTrash2,
LuX,
} from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -26,6 +28,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ProBadge } from "@/components/ui/pro-badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@@ -100,6 +103,11 @@ export function ProfileInfoDialog({
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);
const [bypassRules, setBypassRules] = React.useState<string[]>([]);
const [newRule, setNewRule] = React.useState("");
React.useEffect(() => {
if (!isOpen || !profile?.group_id) {
@@ -117,11 +125,33 @@ export function ProfileInfoDialog({
})();
}, [isOpen, profile?.group_id]);
React.useEffect(() => {
if (!isOpen || !profile?.extension_group_id) {
setExtensionGroupName(null);
return;
}
(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);
setNewRule("");
}
}, [isOpen]);
if (isOpen && profile) {
setBypassRules(profile.proxy_bypass_rules ?? []);
}
}, [isOpen, profile]);
if (!profile) return null;
@@ -163,6 +193,31 @@ export function ProfileInfoDialog({
action();
};
const updateBypassRules = async (rules: string[]) => {
if (!profile) return;
try {
await invoke("update_profile_proxy_bypass_rules", {
profileId: profile.id,
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));
};
const infoFields: { label: string; value: React.ReactNode }[] = [
{
label: t("profileInfo.fields.profileId"),
@@ -203,6 +258,10 @@ export function ProfileInfoDialog({
label: t("profileInfo.fields.group"),
value: groupName ?? t("profileInfo.values.none"),
},
{
label: t("profileInfo.fields.extensionGroup"),
value: extensionGroupName ?? t("profileInfo.values.none"),
},
{
label: t("profileInfo.fields.tags"),
value:
@@ -349,6 +408,9 @@ export function ProfileInfoDialog({
<TabsTrigger value="info" className="flex-1">
{t("profileInfo.tabs.info")}
</TabsTrigger>
<TabsTrigger value="network" className="flex-1">
{t("profileInfo.tabs.network")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
{t("profileInfo.tabs.settings")}
</TabsTrigger>
@@ -365,6 +427,63 @@ export function ProfileInfoDialog({
))}
</div>
</TabsContent>
<TabsContent value="network">
<div className="flex flex-col gap-3 py-2">
<div>
<h4 className="text-sm font-medium">
{t("profileInfo.network.bypassRules")}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{t("profileInfo.network.bypassRulesDescription")}
</p>
</div>
<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="w-4 h-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 max-h-48 overflow-y-auto">
{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="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("profileInfo.network.ruleTypes")}
</p>
</div>
</TabsContent>
<TabsContent value="settings">
<div className="flex flex-col py-1">
{visibleActions.map((action) => (
+73
View File
@@ -0,0 +1,73 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import type { Extension, ExtensionGroup } from "@/types";
export function useExtensionEvents() {
const [extensions, setExtensions] = useState<Extension[]>([]);
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadExtensions = useCallback(async () => {
try {
const exts = await invoke<Extension[]>("list_extensions");
setExtensions(exts);
setError(null);
} catch (err: unknown) {
console.error("Failed to load extensions:", err);
setExtensions([]);
}
}, []);
const loadExtensionGroups = useCallback(async () => {
try {
const groups = await invoke<ExtensionGroup[]>("list_extension_groups");
setExtensionGroups(groups);
setError(null);
} catch (err: unknown) {
console.error("Failed to load extension groups:", err);
setExtensionGroups([]);
}
}, []);
const loadAll = useCallback(async () => {
await Promise.all([loadExtensions(), loadExtensionGroups()]);
}, [loadExtensions, loadExtensionGroups]);
useEffect(() => {
let unlisten: (() => void) | undefined;
const setup = async () => {
try {
await loadAll();
unlisten = await listen("extensions-changed", () => {
void loadAll();
});
} catch (err) {
console.error("Failed to setup extension event listeners:", err);
setError(
`Failed to setup extension event listeners: ${JSON.stringify(err)}`,
);
} finally {
setIsLoading(false);
}
};
void setup();
return () => {
if (unlisten) unlisten();
};
}, [loadAll]);
return {
extensions,
extensionGroups,
isLoading,
error,
loadExtensions,
loadExtensionGroups,
loadAll,
};
}
+50 -2
View File
@@ -146,7 +146,8 @@
"groups": "Groups",
"syncService": "Account",
"integrations": "Integrations",
"importProfile": "Import Profile"
"importProfile": "Import Profile",
"extensions": "Extensions"
}
},
"profiles": {
@@ -682,6 +683,7 @@
"title": "Profile Details",
"tabs": {
"info": "Info",
"network": "Network",
"settings": "Settings"
},
"fields": {
@@ -695,7 +697,8 @@
"syncStatus": "Sync Status",
"lastLaunched": "Last Launched",
"hostOs": "Host OS",
"ephemeral": "Ephemeral"
"ephemeral": "Ephemeral",
"extensionGroup": "Extension Group"
},
"values": {
"none": "None",
@@ -703,10 +706,55 @@
"copied": "Copied!",
"yes": "Yes"
},
"network": {
"bypassRules": "Proxy Bypass Rules",
"bypassRulesDescription": "Requests matching these rules will connect directly, bypassing the proxy.",
"addRule": "Add Rule",
"rulePlaceholder": "e.g. example.com, 192.168.1.*, .*\\.local",
"noRules": "No bypass rules configured.",
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns."
},
"actions": {
"manageCookies": "Manage Cookies"
}
},
"extensions": {
"title": "Extensions",
"description": "Manage browser extensions and extension groups for your profiles.",
"upload": "Upload",
"delete": "Delete",
"extensionsTab": "Extensions",
"groupsTab": "Groups",
"managedNotice": "Extensions managed here will replace any manually installed extensions in profiles when launched.",
"proRequired": "Extension management is a Pro feature",
"empty": "No extensions uploaded yet.",
"noGroups": "No extension groups created yet.",
"createGroup": "Create Group",
"addToGroup": "Add extension...",
"removeFromGroup": "Remove from group",
"deleteGroup": "Delete group",
"extensionGroup": "Extension Group",
"compatibility": {
"label": "Compatibility",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium & Firefox"
},
"selectedFile": "Selected file",
"namePlaceholder": "Extension name",
"groupNamePlaceholder": "Group name",
"uploadSuccess": "Extension uploaded successfully",
"deleteSuccess": "Extension deleted successfully",
"groupCreateSuccess": "Extension group created successfully",
"groupUpdateSuccess": "Extension group updated successfully",
"groupDeleteSuccess": "Extension group deleted successfully",
"deleteConfirmTitle": "Delete Extension",
"deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleteGroupConfirmTitle": "Delete Extension Group",
"deleteGroupConfirmDescription": "Are you sure you want to delete the group \"{{name}}\"? This action cannot be undone.",
"invalidFileType": "Invalid file type. Please upload a .crx, .xpi, or .zip file.",
"readError": "Failed to read the extension file."
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "Fingerprint editing is a Pro feature",
+50 -2
View File
@@ -146,7 +146,8 @@
"groups": "Grupos",
"syncService": "Cuenta",
"integrations": "Integraciones",
"importProfile": "Importar Perfil"
"importProfile": "Importar Perfil",
"extensions": "Extensiones"
}
},
"profiles": {
@@ -682,6 +683,7 @@
"title": "Detalles del Perfil",
"tabs": {
"info": "Info",
"network": "Red",
"settings": "Configuración"
},
"fields": {
@@ -695,7 +697,8 @@
"syncStatus": "Estado de Sincronización",
"lastLaunched": "Último Lanzamiento",
"hostOs": "SO Host",
"ephemeral": "Efímero"
"ephemeral": "Efímero",
"extensionGroup": "Grupo de Extensiones"
},
"values": {
"none": "Ninguno",
@@ -703,10 +706,55 @@
"copied": "¡Copiado!",
"yes": "Sí"
},
"network": {
"bypassRules": "Reglas de Omisión de Proxy",
"bypassRulesDescription": "Las solicitudes que coincidan con estas reglas se conectarán directamente, omitiendo el proxy.",
"addRule": "Agregar Regla",
"rulePlaceholder": "ej. example.com, 192.168.1.*, .*\\.local",
"noRules": "No hay reglas de omisión configuradas.",
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex."
},
"actions": {
"manageCookies": "Administrar Cookies"
}
},
"extensions": {
"title": "Extensiones",
"description": "Administra extensiones de navegador y grupos de extensiones para tus perfiles.",
"upload": "Subir",
"delete": "Eliminar",
"extensionsTab": "Extensiones",
"groupsTab": "Grupos",
"managedNotice": "Las extensiones administradas aquí reemplazarán cualquier extensión instalada manualmente en los perfiles al iniciarlos.",
"proRequired": "La gestión de extensiones es una función Pro",
"empty": "No se han subido extensiones aún.",
"noGroups": "No se han creado grupos de extensiones aún.",
"createGroup": "Crear Grupo",
"addToGroup": "Agregar extensión...",
"removeFromGroup": "Eliminar del grupo",
"deleteGroup": "Eliminar grupo",
"extensionGroup": "Grupo de Extensiones",
"compatibility": {
"label": "Compatibilidad",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium y Firefox"
},
"selectedFile": "Archivo seleccionado",
"namePlaceholder": "Nombre de la extensión",
"groupNamePlaceholder": "Nombre del grupo",
"uploadSuccess": "Extensión subida exitosamente",
"deleteSuccess": "Extensión eliminada exitosamente",
"groupCreateSuccess": "Grupo de extensiones creado exitosamente",
"groupUpdateSuccess": "Grupo de extensiones actualizado exitosamente",
"groupDeleteSuccess": "Grupo de extensiones eliminado exitosamente",
"deleteConfirmTitle": "Eliminar Extensión",
"deleteConfirmDescription": "¿Estás seguro de que deseas eliminar \"{{name}}\"? Esta acción no se puede deshacer.",
"deleteGroupConfirmTitle": "Eliminar Grupo de Extensiones",
"deleteGroupConfirmDescription": "¿Estás seguro de que deseas eliminar el grupo \"{{name}}\"? Esta acción no se puede deshacer.",
"invalidFileType": "Tipo de archivo no válido. Suba un archivo .crx, .xpi o .zip.",
"readError": "No se pudo leer el archivo de extensión."
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "La edición de huellas digitales es una función Pro",
+50 -2
View File
@@ -146,7 +146,8 @@
"groups": "Groupes",
"syncService": "Compte",
"integrations": "Intégrations",
"importProfile": "Importer un profil"
"importProfile": "Importer un profil",
"extensions": "Extensions"
}
},
"profiles": {
@@ -682,6 +683,7 @@
"title": "Détails du Profil",
"tabs": {
"info": "Info",
"network": "Réseau",
"settings": "Paramètres"
},
"fields": {
@@ -695,7 +697,8 @@
"syncStatus": "État de Synchronisation",
"lastLaunched": "Dernier Lancement",
"hostOs": "OS Hôte",
"ephemeral": "Éphémère"
"ephemeral": "Éphémère",
"extensionGroup": "Groupe d'Extensions"
},
"values": {
"none": "Aucun",
@@ -703,10 +706,55 @@
"copied": "Copié !",
"yes": "Oui"
},
"network": {
"bypassRules": "Règles de Contournement du Proxy",
"bypassRulesDescription": "Les requêtes correspondant à ces règles se connecteront directement, contournant le proxy.",
"addRule": "Ajouter une Règle",
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
"noRules": "Aucune règle de contournement configurée.",
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières."
},
"actions": {
"manageCookies": "Gérer les Cookies"
}
},
"extensions": {
"title": "Extensions",
"description": "Gérez les extensions de navigateur et les groupes d'extensions pour vos profils.",
"upload": "Télécharger",
"delete": "Supprimer",
"extensionsTab": "Extensions",
"groupsTab": "Groupes",
"managedNotice": "Les extensions gérées ici remplaceront toutes les extensions installées manuellement dans les profils lors du lancement.",
"proRequired": "La gestion des extensions est une fonctionnalité Pro",
"empty": "Aucune extension téléchargée pour l'instant.",
"noGroups": "Aucun groupe d'extensions créé pour l'instant.",
"createGroup": "Créer un Groupe",
"addToGroup": "Ajouter une extension...",
"removeFromGroup": "Retirer du groupe",
"deleteGroup": "Supprimer le groupe",
"extensionGroup": "Groupe d'Extensions",
"compatibility": {
"label": "Compatibilité",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium et Firefox"
},
"selectedFile": "Fichier sélectionné",
"namePlaceholder": "Nom de l'extension",
"groupNamePlaceholder": "Nom du groupe",
"uploadSuccess": "Extension téléchargée avec succès",
"deleteSuccess": "Extension supprimée avec succès",
"groupCreateSuccess": "Groupe d'extensions créé avec succès",
"groupUpdateSuccess": "Groupe d'extensions mis à jour avec succès",
"groupDeleteSuccess": "Groupe d'extensions supprimé avec succès",
"deleteConfirmTitle": "Supprimer l'Extension",
"deleteConfirmDescription": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.",
"deleteGroupConfirmTitle": "Supprimer le Groupe d'Extensions",
"deleteGroupConfirmDescription": "Êtes-vous sûr de vouloir supprimer le groupe \"{{name}}\" ? Cette action est irréversible.",
"invalidFileType": "Type de fichier non valide. Veuillez télécharger un fichier .crx, .xpi ou .zip.",
"readError": "Impossible de lire le fichier d'extension."
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "La modification d'empreinte est une fonctionnalité Pro",
+50 -2
View File
@@ -146,7 +146,8 @@
"groups": "グループ",
"syncService": "アカウント",
"integrations": "統合",
"importProfile": "プロファイルをインポート"
"importProfile": "プロファイルをインポート",
"extensions": "拡張機能"
}
},
"profiles": {
@@ -682,6 +683,7 @@
"title": "プロフィール詳細",
"tabs": {
"info": "情報",
"network": "ネットワーク",
"settings": "設定"
},
"fields": {
@@ -695,7 +697,8 @@
"syncStatus": "同期ステータス",
"lastLaunched": "最終起動",
"hostOs": "ホストOS",
"ephemeral": "エフェメラル"
"ephemeral": "エフェメラル",
"extensionGroup": "拡張機能グループ"
},
"values": {
"none": "なし",
@@ -703,10 +706,55 @@
"copied": "コピーしました!",
"yes": "はい"
},
"network": {
"bypassRules": "プロキシバイパスルール",
"bypassRulesDescription": "これらのルールに一致するリクエストは、プロキシをバイパスして直接接続します。",
"addRule": "ルールを追加",
"rulePlaceholder": "例: example.com, 192.168.1.*, .*\\.local",
"noRules": "バイパスルールは設定されていません。",
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。"
},
"actions": {
"manageCookies": "Cookieを管理"
}
},
"extensions": {
"title": "拡張機能",
"description": "プロファイル用のブラウザ拡張機能と拡張機能グループを管理します。",
"upload": "アップロード",
"delete": "削除",
"extensionsTab": "拡張機能",
"groupsTab": "グループ",
"managedNotice": "ここで管理される拡張機能は、起動時にプロファイルに手動でインストールされた拡張機能を置き換えます。",
"proRequired": "拡張機能管理はプロ機能です",
"empty": "まだ拡張機能がアップロードされていません。",
"noGroups": "まだ拡張機能グループが作成されていません。",
"createGroup": "グループを作成",
"addToGroup": "拡張機能を追加...",
"removeFromGroup": "グループから削除",
"deleteGroup": "グループを削除",
"extensionGroup": "拡張機能グループ",
"compatibility": {
"label": "互換性",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium & Firefox"
},
"selectedFile": "選択されたファイル",
"namePlaceholder": "拡張機能名",
"groupNamePlaceholder": "グループ名",
"uploadSuccess": "拡張機能が正常にアップロードされました",
"deleteSuccess": "拡張機能が正常に削除されました",
"groupCreateSuccess": "拡張機能グループが正常に作成されました",
"groupUpdateSuccess": "拡張機能グループが正常に更新されました",
"groupDeleteSuccess": "拡張機能グループが正常に削除されました",
"deleteConfirmTitle": "拡張機能を削除",
"deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"deleteGroupConfirmTitle": "拡張機能グループを削除",
"deleteGroupConfirmDescription": "グループ「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"invalidFileType": "無効なファイルタイプです。.crx、.xpi、または .zip ファイルをアップロードしてください。",
"readError": "拡張機能ファイルの読み取りに失敗しました。"
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "フィンガープリント編集はプロ機能です",
+50 -2
View File
@@ -146,7 +146,8 @@
"groups": "Grupos",
"syncService": "Conta",
"integrations": "Integrações",
"importProfile": "Importar Perfil"
"importProfile": "Importar Perfil",
"extensions": "Extensões"
}
},
"profiles": {
@@ -682,6 +683,7 @@
"title": "Detalhes do Perfil",
"tabs": {
"info": "Info",
"network": "Rede",
"settings": "Configurações"
},
"fields": {
@@ -695,7 +697,8 @@
"syncStatus": "Status de Sincronização",
"lastLaunched": "Último Lançamento",
"hostOs": "SO Host",
"ephemeral": "Efêmero"
"ephemeral": "Efêmero",
"extensionGroup": "Grupo de Extensões"
},
"values": {
"none": "Nenhum",
@@ -703,10 +706,55 @@
"copied": "Copiado!",
"yes": "Sim"
},
"network": {
"bypassRules": "Regras de Bypass de Proxy",
"bypassRulesDescription": "Solicitações que correspondam a estas regras se conectarão diretamente, ignorando o proxy.",
"addRule": "Adicionar Regra",
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
"noRules": "Nenhuma regra de bypass configurada.",
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex."
},
"actions": {
"manageCookies": "Gerenciar Cookies"
}
},
"extensions": {
"title": "Extensões",
"description": "Gerencie extensões de navegador e grupos de extensões para seus perfis.",
"upload": "Enviar",
"delete": "Excluir",
"extensionsTab": "Extensões",
"groupsTab": "Grupos",
"managedNotice": "As extensões gerenciadas aqui substituirão quaisquer extensões instaladas manualmente nos perfis ao serem iniciados.",
"proRequired": "O gerenciamento de extensões é um recurso Pro",
"empty": "Nenhuma extensão enviada ainda.",
"noGroups": "Nenhum grupo de extensões criado ainda.",
"createGroup": "Criar Grupo",
"addToGroup": "Adicionar extensão...",
"removeFromGroup": "Remover do grupo",
"deleteGroup": "Excluir grupo",
"extensionGroup": "Grupo de Extensões",
"compatibility": {
"label": "Compatibilidade",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium e Firefox"
},
"selectedFile": "Arquivo selecionado",
"namePlaceholder": "Nome da extensão",
"groupNamePlaceholder": "Nome do grupo",
"uploadSuccess": "Extensão enviada com sucesso",
"deleteSuccess": "Extensão excluída com sucesso",
"groupCreateSuccess": "Grupo de extensões criado com sucesso",
"groupUpdateSuccess": "Grupo de extensões atualizado com sucesso",
"groupDeleteSuccess": "Grupo de extensões excluído com sucesso",
"deleteConfirmTitle": "Excluir Extensão",
"deleteConfirmDescription": "Tem certeza de que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.",
"deleteGroupConfirmTitle": "Excluir Grupo de Extensões",
"deleteGroupConfirmDescription": "Tem certeza de que deseja excluir o grupo \"{{name}}\"? Esta ação não pode ser desfeita.",
"invalidFileType": "Tipo de arquivo inválido. Envie um arquivo .crx, .xpi ou .zip.",
"readError": "Falha ao ler o arquivo de extensão."
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "A edição de impressão digital é um recurso Pro",
+50 -2
View File
@@ -146,7 +146,8 @@
"groups": "Группы",
"syncService": "Аккаунт",
"integrations": "Интеграции",
"importProfile": "Импорт профиля"
"importProfile": "Импорт профиля",
"extensions": "Расширения"
}
},
"profiles": {
@@ -682,6 +683,7 @@
"title": "Детали профиля",
"tabs": {
"info": "Информация",
"network": "Сеть",
"settings": "Настройки"
},
"fields": {
@@ -695,7 +697,8 @@
"syncStatus": "Статус синхронизации",
"lastLaunched": "Последний запуск",
"hostOs": "ОС хоста",
"ephemeral": "Эфемерный"
"ephemeral": "Эфемерный",
"extensionGroup": "Группа расширений"
},
"values": {
"none": "Нет",
@@ -703,10 +706,55 @@
"copied": "Скопировано!",
"yes": "Да"
},
"network": {
"bypassRules": "Правила обхода прокси",
"bypassRulesDescription": "Запросы, соответствующие этим правилам, будут подключаться напрямую, минуя прокси.",
"addRule": "Добавить правило",
"rulePlaceholder": "напр. example.com, 192.168.1.*, .*\\.local",
"noRules": "Правила обхода не настроены.",
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений."
},
"actions": {
"manageCookies": "Управление Cookie"
}
},
"extensions": {
"title": "Расширения",
"description": "Управляйте расширениями браузера и группами расширений для ваших профилей.",
"upload": "Загрузить",
"delete": "Удалить",
"extensionsTab": "Расширения",
"groupsTab": "Группы",
"managedNotice": "Расширения, управляемые здесь, заменят все вручную установленные расширения в профилях при запуске.",
"proRequired": "Управление расширениями — функция Pro",
"empty": "Расширения ещё не загружены.",
"noGroups": "Группы расширений ещё не созданы.",
"createGroup": "Создать группу",
"addToGroup": "Добавить расширение...",
"removeFromGroup": "Удалить из группы",
"deleteGroup": "Удалить группу",
"extensionGroup": "Группа расширений",
"compatibility": {
"label": "Совместимость",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium и Firefox"
},
"selectedFile": "Выбранный файл",
"namePlaceholder": "Название расширения",
"groupNamePlaceholder": "Название группы",
"uploadSuccess": "Расширение успешно загружено",
"deleteSuccess": "Расширение успешно удалено",
"groupCreateSuccess": "Группа расширений успешно создана",
"groupUpdateSuccess": "Группа расширений успешно обновлена",
"groupDeleteSuccess": "Группа расширений успешно удалена",
"deleteConfirmTitle": "Удалить расширение",
"deleteConfirmDescription": "Вы уверены, что хотите удалить «{{name}}»? Это действие нельзя отменить.",
"deleteGroupConfirmTitle": "Удалить группу расширений",
"deleteGroupConfirmDescription": "Вы уверены, что хотите удалить группу «{{name}}»? Это действие нельзя отменить.",
"invalidFileType": "Недопустимый тип файла. Загрузите файл .crx, .xpi или .zip.",
"readError": "Не удалось прочитать файл расширения."
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "Редактирование отпечатка — функция Pro",
+50 -2
View File
@@ -146,7 +146,8 @@
"groups": "分组",
"syncService": "账户",
"integrations": "集成",
"importProfile": "导入配置文件"
"importProfile": "导入配置文件",
"extensions": "扩展程序"
}
},
"profiles": {
@@ -682,6 +683,7 @@
"title": "配置文件详情",
"tabs": {
"info": "信息",
"network": "网络",
"settings": "设置"
},
"fields": {
@@ -695,7 +697,8 @@
"syncStatus": "同步状态",
"lastLaunched": "上次启动",
"hostOs": "主机操作系统",
"ephemeral": "临时"
"ephemeral": "临时",
"extensionGroup": "扩展程序组"
},
"values": {
"none": "无",
@@ -703,10 +706,55 @@
"copied": "已复制!",
"yes": "是"
},
"network": {
"bypassRules": "代理绕过规则",
"bypassRulesDescription": "匹配这些规则的请求将直接连接,绕过代理。",
"addRule": "添加规则",
"rulePlaceholder": "例如 example.com, 192.168.1.*, .*\\.local",
"noRules": "未配置绕过规则。",
"ruleTypes": "支持主机名、IP地址和正则表达式模式。"
},
"actions": {
"manageCookies": "管理 Cookie"
}
},
"extensions": {
"title": "扩展程序",
"description": "管理配置文件的浏览器扩展程序和扩展程序组。",
"upload": "上传",
"delete": "删除",
"extensionsTab": "扩展程序",
"groupsTab": "分组",
"managedNotice": "此处管理的扩展程序将在启动时替换配置文件中手动安装的所有扩展程序。",
"proRequired": "扩展程序管理是 Pro 功能",
"empty": "尚未上传任何扩展程序。",
"noGroups": "尚未创建任何扩展程序组。",
"createGroup": "创建分组",
"addToGroup": "添加扩展程序...",
"removeFromGroup": "从分组中移除",
"deleteGroup": "删除分组",
"extensionGroup": "扩展程序组",
"compatibility": {
"label": "兼容性",
"chromium": "Chromium",
"firefox": "Firefox",
"both": "Chromium 和 Firefox"
},
"selectedFile": "已选文件",
"namePlaceholder": "扩展程序名称",
"groupNamePlaceholder": "分组名称",
"uploadSuccess": "扩展程序上传成功",
"deleteSuccess": "扩展程序删除成功",
"groupCreateSuccess": "扩展程序组创建成功",
"groupUpdateSuccess": "扩展程序组更新成功",
"groupDeleteSuccess": "扩展程序组删除成功",
"deleteConfirmTitle": "删除扩展程序",
"deleteConfirmDescription": "确定要删除「{{name}}」吗?此操作无法撤消。",
"deleteGroupConfirmTitle": "删除扩展程序组",
"deleteGroupConfirmDescription": "确定要删除分组「{{name}}」吗?此操作无法撤消。",
"invalidFileType": "无效的文件类型。请上传 .crx、.xpi 或 .zip 文件。",
"readError": "读取扩展程序文件失败。"
},
"pro": {
"badge": "PRO",
"fingerprintLocked": "指纹编辑是 Pro 功能",
+24
View File
@@ -31,6 +31,30 @@ export interface BrowserProfile {
last_sync?: number; // Timestamp of last successful sync (epoch seconds)
host_os?: string; // OS where profile was created ("macos", "windows", "linux")
ephemeral?: boolean;
extension_group_id?: string;
proxy_bypass_rules?: string[];
}
export interface Extension {
id: string;
name: string;
file_name: string;
file_type: string;
browser_compatibility: string[];
created_at: number;
updated_at: number;
sync_enabled?: boolean;
last_sync?: number;
}
export interface ExtensionGroup {
id: string;
name: string;
extension_ids: string[];
created_at: number;
updated_at: number;
sync_enabled?: boolean;
last_sync?: number;
}
export type SyncMode = "Disabled" | "Regular" | "Encrypted";