feat: dns block lists

This commit is contained in:
zhom
2026-03-31 14:21:31 +04:00
parent cb8093fbde
commit 35723de96a
39 changed files with 1880 additions and 579 deletions
+6 -1
View File
@@ -68,7 +68,12 @@ export function BandwidthMiniChart({
)}
>
<div className="flex-1 h-3 pointer-events-none">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={1}
minHeight={1}
>
<AreaChart
data={chartData}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { WindowDragArea } from "@/components/window-drag-area";
import { setupLogging } from "@/lib/logger";
export function ClientProviders({ children }: { children: React.ReactNode }) {
useEffect(() => {
void setupLogging();
}, []);
return (
<I18nProvider>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>{children}</TooltipProvider>
<Toaster />
</CustomThemeProvider>
</I18nProvider>
);
}
+42
View File
@@ -84,6 +84,7 @@ interface CreateProfileDialogProps {
groupId?: string;
extensionGroupId?: string;
ephemeral?: boolean;
dnsBlocklist?: string;
}) => Promise<void>;
selectedGroupId?: string;
crossOsUnlocked?: boolean;
@@ -124,6 +125,7 @@ export function CreateProfileDialog({
useState<BrowserTypeString | null>(null);
const [selectedProxyId, setSelectedProxyId] = useState<string>();
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
@@ -395,6 +397,7 @@ export function CreateProfileDialog({
selectedGroupId !== "default" ? selectedGroupId : undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
});
} else {
// Default to Camoufox
@@ -420,6 +423,7 @@ export function CreateProfileDialog({
selectedGroupId !== "default" ? selectedGroupId : undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
});
}
} else {
@@ -443,6 +447,7 @@ export function CreateProfileDialog({
releaseType: bestVersion.releaseType,
proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
dnsBlocklist: dnsBlocklist || undefined,
});
}
@@ -1162,6 +1167,43 @@ export function CreateProfileDialog({
)}
</div>
{/* DNS Blocklist */}
<div className="space-y-2">
<Label>{t("dnsBlocklist.title")}</Label>
<Select
value={dnsBlocklist || "none"}
onValueChange={(val) => {
setDnsBlocklist(val === "none" ? "" : val);
}}
>
<SelectTrigger>
<SelectValue
placeholder={t("dnsBlocklist.none")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("dnsBlocklist.none")}
</SelectItem>
<SelectItem value="light">
{t("dnsBlocklist.light")}
</SelectItem>
<SelectItem value="normal">
{t("dnsBlocklist.normal")}
</SelectItem>
<SelectItem value="pro">
{t("dnsBlocklist.pro")}
</SelectItem>
<SelectItem value="pro_plus">
{t("dnsBlocklist.proPlus")}
</SelectItem>
<SelectItem value="ultimate">
{t("dnsBlocklist.ultimate")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Extension Group */}
{extensionGroups.length > 0 && (
<div className="space-y-2">
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuRefreshCw } from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface BlocklistCacheStatus {
level: string;
display_name: string;
entry_count: number;
file_size_bytes: number;
last_updated: number | null;
is_fresh: boolean;
is_cached: boolean;
}
interface DnsBlocklistDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function DnsBlocklistDialog({
isOpen,
onClose,
}: DnsBlocklistDialogProps) {
const { t } = useTranslation();
const [statuses, setStatuses] = useState<BlocklistCacheStatus[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const loadStatuses = useCallback(async () => {
try {
const result = await invoke<BlocklistCacheStatus[]>(
"get_dns_blocklist_cache_status",
);
setStatuses(result);
} catch (e) {
console.error("Failed to load blocklist status:", e);
}
}, []);
useEffect(() => {
if (isOpen) {
void loadStatuses();
}
}, [isOpen, loadStatuses]);
const handleRefreshAll = async () => {
setIsRefreshing(true);
try {
await invoke("refresh_dns_blocklists");
await loadStatuses();
} catch (e) {
console.error("Failed to refresh blocklists:", e);
} finally {
setIsRefreshing(false);
}
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const formatDate = (timestamp: number | null) => {
if (!timestamp) return t("dnsBlocklist.notCached");
return new Date(timestamp * 1000).toLocaleString();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("dnsBlocklist.title")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t("dnsBlocklist.settingsDescription")}
</p>
<div className="space-y-3">
{statuses.map((status) => (
<div
key={status.level}
className="flex items-center justify-between rounded-md border border-border p-3"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{status.display_name}
</span>
{status.is_cached ? (
status.is_fresh ? (
<Badge variant="default" className="text-[10px] px-1.5">
{t("dnsBlocklist.fresh")}
</Badge>
) : (
<Badge variant="secondary" className="text-[10px] px-1.5">
{t("dnsBlocklist.stale")}
</Badge>
)
) : (
<Badge
variant="outline"
className="text-[10px] px-1.5 text-muted-foreground"
>
{t("dnsBlocklist.notCached")}
</Badge>
)}
</div>
{status.is_cached && (
<div className="text-xs text-muted-foreground">
{status.entry_count.toLocaleString()}{" "}
{t("dnsBlocklist.domains")} &middot;{" "}
{formatSize(status.file_size_bytes)} &middot;{" "}
{formatDate(status.last_updated)}
</div>
)}
</div>
</div>
))}
</div>
<Button
onClick={handleRefreshAll}
disabled={isRefreshing}
variant="outline"
className="w-full"
>
<LuRefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("dnsBlocklist.refreshAll")}
</Button>
</DialogContent>
</Dialog>
);
}
+14
View File
@@ -31,6 +31,7 @@ import {
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import {
ProfileBypassRulesDialog,
ProfileDnsBlocklistDialog,
ProfileInfoDialog,
} from "@/components/profile-info-dialog";
import { Badge } from "@/components/ui/badge";
@@ -934,6 +935,8 @@ export function ProfilesDataTable({
React.useState<BrowserProfile | null>(null);
const [bypassRulesProfile, setBypassRulesProfile] =
React.useState<BrowserProfile | null>(null);
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
React.useState<BrowserProfile | null>(null);
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
new Set(),
);
@@ -2674,6 +2677,9 @@ export function ProfilesDataTable({
onOpenBypassRules={(profile) => {
setBypassRulesProfile(profile);
}}
onOpenDnsBlocklist={(profile) => {
setDnsBlocklistProfile(profile);
}}
onCloneProfile={onCloneProfile}
onLaunchWithSync={onLaunchWithSync}
onDeleteProfile={(profile) => {
@@ -2756,6 +2762,14 @@ export function ProfilesDataTable({
profileId={bypassRulesProfile?.id ?? null}
initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []}
/>
<ProfileDnsBlocklistDialog
isOpen={dnsBlocklistProfile !== null}
onClose={() => {
setDnsBlocklistProfile(null);
}}
profileId={dnsBlocklistProfile?.id ?? null}
currentLevel={dnsBlocklistProfile?.dns_blocklist ?? null}
/>
</>
);
}
+107 -3
View File
@@ -17,6 +17,7 @@ import {
LuPuzzle,
LuRefreshCw,
LuSettings,
LuShield,
LuShieldCheck,
LuTrash2,
LuUsers,
@@ -64,6 +65,7 @@ interface ProfileInfoDialogProps {
onOpenCookieManagement?: (profile: BrowserProfile) => void;
onAssignExtensionGroup?: (profileIds: string[]) => void;
onOpenBypassRules?: (profile: BrowserProfile) => void;
onOpenDnsBlocklist?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void;
onLaunchWithSync?: (profile: BrowserProfile) => void;
@@ -110,6 +112,7 @@ export function ProfileInfoDialog({
onOpenCookieManagement,
onAssignExtensionGroup,
onOpenBypassRules,
onOpenDnsBlocklist,
onCloneProfile,
onDeleteProfile,
onLaunchWithSync,
@@ -315,9 +318,8 @@ export function ProfileInfoDialog({
onClick: () => {
handleAction(() => onAssignExtensionGroup?.([profile.id]));
},
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
runningBadge: isRunning && crossOsUnlocked,
disabled: isDisabled,
runningBadge: isRunning,
hidden: profile.ephemeral === true,
},
{
@@ -327,6 +329,13 @@ export function ProfileInfoDialog({
handleAction(() => onOpenBypassRules?.(profile));
},
},
{
icon: <LuShield className="w-4 h-4" />,
label: t("dnsBlocklist.title"),
onClick: () => {
handleAction(() => onOpenDnsBlocklist?.(profile));
},
},
{
icon: <LuTrash2 className="w-4 h-4" />,
label: t("profiles.actions.delete"),
@@ -455,6 +464,16 @@ export function ProfileInfoDialog({
: t("profileInfo.values.never")
}
/>
<InfoCard
label={t("dnsBlocklist.title")}
value={
profile.dns_blocklist
? t(
`dnsBlocklist.${profile.dns_blocklist === "pro_plus" ? "proPlus" : profile.dns_blocklist}`,
)
: t("dnsBlocklist.none")
}
/>
</div>
{/* Sync */}
@@ -563,6 +582,91 @@ export function ProfileInfoDialog({
);
}
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")}
</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.save", "Save")}
</Button>
</DialogContent>
</Dialog>
);
}
interface ProfileBypassRulesDialogProps {
isOpen: boolean;
onClose: () => void;
File diff suppressed because it is too large Load Diff
+74 -43
View File
@@ -1,7 +1,13 @@
"use client";
import { ThemeProvider } from "next-themes";
import { useEffect, useState } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { applyThemeColors, clearThemeColors } from "@/lib/themes";
interface AppSettings {
@@ -10,43 +16,62 @@ interface AppSettings {
custom_theme?: Record<string, string>;
}
interface ThemeContextValue {
theme: string;
setTheme: (theme: string) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: "system",
setTheme: () => {},
});
export function useTheme() {
return useContext(ThemeContext);
}
interface CustomThemeProviderProps {
children: React.ReactNode;
}
function resolveSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function applyClassToHtml(theme: string) {
const resolved = theme === "system" ? resolveSystemTheme() : theme;
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(resolved);
}
export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const [isLoading, setIsLoading] = useState(true);
const [defaultTheme, setDefaultTheme] = useState<string>("system");
const [_mounted, setMounted] = useState(false);
const [theme, setThemeState] = useState("system");
useEffect(() => {
setMounted(true);
const setTheme = useCallback((newTheme: string) => {
setThemeState(newTheme);
if (newTheme === "custom") {
applyClassToHtml("dark");
} else {
applyClassToHtml(newTheme);
}
}, []);
// Load initial theme from Tauri settings
useEffect(() => {
const loadTheme = async () => {
try {
// Lazy import to avoid pulling Tauri API on SSR
const { invoke } = await import("@tauri-apps/api/core");
const settings = await invoke<AppSettings>("get_app_settings");
const themeValue = settings?.theme ?? "system";
console.log("[theme-provider] Loaded settings:", {
theme: themeValue,
hasCustomTheme: !!settings?.custom_theme,
customThemeKeys: settings?.custom_theme
? Object.keys(settings.custom_theme).length
: 0,
});
if (
themeValue === "light" ||
themeValue === "dark" ||
themeValue === "system"
) {
setDefaultTheme(themeValue);
} else if (themeValue === "custom") {
setDefaultTheme("dark");
if (themeValue === "custom") {
setThemeState("custom");
applyClassToHtml("dark");
if (
settings.custom_theme &&
Object.keys(settings.custom_theme).length > 0
@@ -57,16 +82,22 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
console.warn("Failed to apply custom theme variables:", error);
}
}
} else if (
themeValue === "light" ||
themeValue === "dark" ||
themeValue === "system"
) {
setThemeState(themeValue);
applyClassToHtml(themeValue);
} else {
setDefaultTheme("system");
applyClassToHtml("system");
}
} catch (error) {
// Failed to load settings; fall back to system (handled by next-themes)
console.warn(
"Failed to load theme settings; defaulting to system:",
error,
);
setDefaultTheme("system");
applyClassToHtml("system");
} finally {
setIsLoading(false);
}
@@ -75,44 +106,44 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
void loadTheme();
}, []);
// Additional effect to ensure custom theme is applied after mount
// Re-apply custom theme after mount
useEffect(() => {
if (!isLoading && _mounted) {
if (!isLoading && theme === "custom") {
const reapplyCustomTheme = async () => {
try {
const { invoke } = await import("@tauri-apps/api/core");
const settings = await invoke<AppSettings>("get_app_settings");
if (settings?.theme === "custom" && settings.custom_theme) {
applyThemeColors(settings.custom_theme);
} else {
clearThemeColors();
}
} catch (error) {
console.warn("Failed to reapply custom theme:", error);
}
};
// Apply after a short delay to ensure CSS has loaded
setTimeout(() => {
void reapplyCustomTheme();
}, 100);
} else if (!isLoading) {
clearThemeColors();
}
}, [isLoading, _mounted]);
}, [isLoading, theme]);
// Listen for system theme changes when in "system" mode
useEffect(() => {
if (theme !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => applyClassToHtml("system");
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [theme]);
const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
if (isLoading) {
// Keep UI simple during initial settings load to avoid flicker
return null;
}
return (
<ThemeProvider
attribute="class"
defaultTheme={defaultTheme}
enableSystem={true}
disableTransitionOnChange={false}
>
{children}
</ThemeProvider>
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
+6 -1
View File
@@ -295,7 +295,12 @@ export function TrafficDetailsDialog({
</div>
<div className="h-[200px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={1}
minHeight={1}
>
<AreaChart
data={chartData}
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
+1 -1
View File
@@ -67,7 +67,7 @@ const ChartContainer = React.forwardRef<
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
<RechartsPrimitive.ResponsiveContainer minWidth={1} minHeight={1}>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import { useTheme } from "@/components/theme-provider";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();