mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-28 07:14:40 +08:00
refactor: frontend (#5068)
* refactor: setting components * refactor: frontend * fix: settings router
This commit is contained in:
@@ -37,7 +37,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
|
||||
);
|
||||
|
||||
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
|
||||
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准
|
||||
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -71,10 +71,11 @@ export const BackupConfigViewer = memo(
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (webdav_url && webdav_username && webdav_password) {
|
||||
onInit();
|
||||
if (!webdav_url || !webdav_username || !webdav_password) {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
void onInit();
|
||||
}, [webdav_url, webdav_username, webdav_password, onInit]);
|
||||
|
||||
const checkForm = () => {
|
||||
const username = usernameRef.current?.value;
|
||||
|
||||
@@ -103,94 +103,97 @@ export const BackupTableViewer = memo(
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{datasource.length > 0 ? (
|
||||
datasource?.map((file, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell component="th" scope="row">
|
||||
{file.platform === "windows" ? (
|
||||
<WindowsIcon className="h-full w-full" />
|
||||
) : file.platform === "linux" ? (
|
||||
<LinuxIcon className="h-full w-full" />
|
||||
) : (
|
||||
<MacIcon className="h-full w-full" />
|
||||
)}
|
||||
{file.filename}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{file.backup_time.fromNow()}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{onExport && (
|
||||
<>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label={t("Export")}
|
||||
size="small"
|
||||
title={t("Export Backup")}
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
await handleExport(file.filename);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 1, height: 24 }}
|
||||
/>
|
||||
</>
|
||||
datasource.map((file) => {
|
||||
const rowKey = `${file.platform}-${file.filename}-${file.backup_time.valueOf()}`;
|
||||
return (
|
||||
<TableRow key={rowKey}>
|
||||
<TableCell component="th" scope="row">
|
||||
{file.platform === "windows" ? (
|
||||
<WindowsIcon className="h-full w-full" />
|
||||
) : file.platform === "linux" ? (
|
||||
<LinuxIcon className="h-full w-full" />
|
||||
) : (
|
||||
<MacIcon className="h-full w-full" />
|
||||
)}
|
||||
<IconButton
|
||||
color="secondary"
|
||||
aria-label={t("Delete")}
|
||||
size="small"
|
||||
title={t("Delete Backup")}
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const confirmed = await window.confirm(
|
||||
t("Confirm to delete this backup file?"),
|
||||
);
|
||||
if (confirmed) {
|
||||
await handleDelete(file.filename);
|
||||
}
|
||||
{file.filename}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{file.backup_time.fromNow()}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 1, height: 24 }}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label={t("Restore")}
|
||||
size="small"
|
||||
title={t("Restore Backup")}
|
||||
disabled={!file.allow_apply}
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const confirmed = await window.confirm(
|
||||
t("Confirm to restore this backup file?"),
|
||||
);
|
||||
if (confirmed) {
|
||||
await handleRestore(file.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RestoreIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
{onExport && (
|
||||
<>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label={t("Export")}
|
||||
size="small"
|
||||
title={t("Export Backup")}
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
await handleExport(file.filename);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 1, height: 24 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
color="secondary"
|
||||
aria-label={t("Delete")}
|
||||
size="small"
|
||||
title={t("Delete Backup")}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const confirmed = window.confirm(
|
||||
t("Confirm to delete this backup file?"),
|
||||
);
|
||||
if (confirmed) {
|
||||
void handleDelete(file.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 1, height: 24 }}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label={t("Restore")}
|
||||
size="small"
|
||||
title={t("Restore Backup")}
|
||||
disabled={!file.allow_apply}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const confirmed = window.confirm(
|
||||
t("Confirm to restore this backup file?"),
|
||||
);
|
||||
if (confirmed) {
|
||||
void handleRestore(file.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RestoreIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} align="center">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -36,16 +37,20 @@ dayjs.extend(customParseFormat);
|
||||
const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss";
|
||||
const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
|
||||
type BackupSource = "local" | "webdav";
|
||||
type CloseButtonPosition = { top: number; left: number } | null;
|
||||
|
||||
export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [dialogPaper, setDialogPaper] = useState<HTMLElement | null>(null);
|
||||
const [closeButtonPosition, setCloseButtonPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
const [dialogPaper, setDialogPaper] = useReducer(
|
||||
(_: HTMLElement | null, next: HTMLElement | null) => next,
|
||||
null as HTMLElement | null,
|
||||
);
|
||||
const [closeButtonPosition, setCloseButtonPosition] = useReducer(
|
||||
(_: CloseButtonPosition, next: CloseButtonPosition) => next,
|
||||
null as CloseButtonPosition,
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);
|
||||
|
||||
@@ -16,7 +16,14 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { useLockFn } from "ahooks";
|
||||
import yaml from "js-yaml";
|
||||
import type { Ref } from "react";
|
||||
import { useEffect, useImperativeHandle, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import MonacoEditor from "react-monaco-editor";
|
||||
|
||||
@@ -35,6 +42,91 @@ const Item = styled(ListItem)(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type NameserverPolicy = Record<string, any>;
|
||||
|
||||
function parseNameserverPolicy(str: string): NameserverPolicy {
|
||||
const result: NameserverPolicy = {};
|
||||
if (!str) return result;
|
||||
|
||||
const ruleRegex = /\s*([^=]+?)\s*=\s*([^,]+)(?:,|$)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = ruleRegex.exec(str)) !== null) {
|
||||
const [, domainsPart, serversPart] = match;
|
||||
|
||||
const domains = [domainsPart.trim()];
|
||||
const servers = serversPart.split(";").map((s) => s.trim());
|
||||
|
||||
domains.forEach((domain) => {
|
||||
result[domain] = servers;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatNameserverPolicy(policy: unknown): string {
|
||||
if (!policy || typeof policy !== "object") return "";
|
||||
|
||||
return Object.entries(policy as Record<string, unknown>)
|
||||
.map(([domain, servers]) => {
|
||||
const serversStr = Array.isArray(servers) ? servers.join(";") : servers;
|
||||
return `${domain}=${serversStr}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatHosts(hosts: unknown): string {
|
||||
if (!hosts || typeof hosts !== "object") return "";
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
Object.entries(hosts as Record<string, unknown>).forEach(
|
||||
([domain, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
const ipsStr = value.join(";");
|
||||
result.push(`${domain}=${ipsStr}`);
|
||||
} else {
|
||||
result.push(`${domain}=${value}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return result.join(", ");
|
||||
}
|
||||
|
||||
function parseHosts(str: string): NameserverPolicy {
|
||||
const result: NameserverPolicy = {};
|
||||
if (!str) return result;
|
||||
|
||||
str.split(",").forEach((item) => {
|
||||
const parts = item.trim().split("=");
|
||||
if (parts.length < 2) return;
|
||||
|
||||
const domain = parts[0].trim();
|
||||
const valueStr = parts.slice(1).join("=").trim();
|
||||
|
||||
if (valueStr.includes(";")) {
|
||||
result[domain] = valueStr
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
result[domain] = valueStr;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseList(str: string): string[] {
|
||||
if (!str?.trim()) return [];
|
||||
return str
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// 默认DNS配置
|
||||
const DEFAULT_DNS_CONFIG = {
|
||||
enable: true,
|
||||
@@ -95,6 +187,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [visualization, setVisualization] = useState(true);
|
||||
const skipYamlSyncRef = useRef(false);
|
||||
const [values, setValues] = useState<{
|
||||
enable: boolean;
|
||||
listen: string;
|
||||
@@ -150,304 +243,91 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
});
|
||||
|
||||
// 用于YAML编辑模式
|
||||
const [yamlContent, setYamlContent] = useState("");
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => {
|
||||
setOpen(true);
|
||||
// 获取DNS配置文件并初始化表单
|
||||
initDnsConfig();
|
||||
},
|
||||
close: () => setOpen(false),
|
||||
}));
|
||||
|
||||
// 初始化DNS配置
|
||||
const initDnsConfig = async () => {
|
||||
try {
|
||||
// 尝试从dns_config.yaml文件读取配置
|
||||
const dnsConfigExists = await invoke<boolean>(
|
||||
"check_dns_config_exists",
|
||||
{},
|
||||
);
|
||||
|
||||
if (dnsConfigExists) {
|
||||
// 如果存在配置文件,加载其内容
|
||||
const dnsConfig = await invoke<string>("get_dns_config_content", {});
|
||||
const config = yaml.load(dnsConfig) as any;
|
||||
|
||||
// 更新表单数据
|
||||
updateValuesFromConfig(config);
|
||||
// 更新YAML编辑器内容
|
||||
setYamlContent(dnsConfig);
|
||||
} else {
|
||||
// 如果不存在配置文件,使用默认值
|
||||
resetToDefaults();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize DNS config", err);
|
||||
resetToDefaults();
|
||||
}
|
||||
};
|
||||
const [yamlContent, setYamlContent] = useReducer(
|
||||
(_: string, next: string) => next,
|
||||
"",
|
||||
);
|
||||
|
||||
// 从配置对象更新表单值
|
||||
const updateValuesFromConfig = (config: any) => {
|
||||
if (!config) return;
|
||||
const updateValuesFromConfig = useCallback(
|
||||
(config: any) => {
|
||||
if (!config) return;
|
||||
|
||||
// 提取dns配置
|
||||
const dnsConfig = config.dns || {};
|
||||
// 提取hosts配置(与dns同级)
|
||||
const hostsConfig = config.hosts || {};
|
||||
const dnsConfig = config.dns || {};
|
||||
const hostsConfig = config.hosts || {};
|
||||
|
||||
const enhancedMode =
|
||||
dnsConfig["enhanced-mode"] || DEFAULT_DNS_CONFIG["enhanced-mode"];
|
||||
const validEnhancedMode =
|
||||
enhancedMode === "fake-ip" || enhancedMode === "redir-host"
|
||||
? enhancedMode
|
||||
: DEFAULT_DNS_CONFIG["enhanced-mode"];
|
||||
const enhancedMode =
|
||||
dnsConfig["enhanced-mode"] || DEFAULT_DNS_CONFIG["enhanced-mode"];
|
||||
const validEnhancedMode =
|
||||
enhancedMode === "fake-ip" || enhancedMode === "redir-host"
|
||||
? enhancedMode
|
||||
: DEFAULT_DNS_CONFIG["enhanced-mode"];
|
||||
|
||||
const fakeIpFilterMode =
|
||||
dnsConfig["fake-ip-filter-mode"] ||
|
||||
DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
|
||||
const validFakeIpFilterMode =
|
||||
fakeIpFilterMode === "blacklist" || fakeIpFilterMode === "whitelist"
|
||||
? fakeIpFilterMode
|
||||
: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
|
||||
const fakeIpFilterMode =
|
||||
dnsConfig["fake-ip-filter-mode"] ||
|
||||
DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
|
||||
const validFakeIpFilterMode =
|
||||
fakeIpFilterMode === "blacklist" || fakeIpFilterMode === "whitelist"
|
||||
? fakeIpFilterMode
|
||||
: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
|
||||
|
||||
setValues({
|
||||
enable: dnsConfig.enable ?? DEFAULT_DNS_CONFIG.enable,
|
||||
listen: dnsConfig.listen ?? DEFAULT_DNS_CONFIG.listen,
|
||||
enhancedMode: validEnhancedMode,
|
||||
fakeIpRange:
|
||||
dnsConfig["fake-ip-range"] ?? DEFAULT_DNS_CONFIG["fake-ip-range"],
|
||||
fakeIpFilterMode: validFakeIpFilterMode,
|
||||
preferH3: dnsConfig["prefer-h3"] ?? DEFAULT_DNS_CONFIG["prefer-h3"],
|
||||
respectRules:
|
||||
dnsConfig["respect-rules"] ?? DEFAULT_DNS_CONFIG["respect-rules"],
|
||||
useHosts: dnsConfig["use-hosts"] ?? DEFAULT_DNS_CONFIG["use-hosts"],
|
||||
useSystemHosts:
|
||||
dnsConfig["use-system-hosts"] ?? DEFAULT_DNS_CONFIG["use-system-hosts"],
|
||||
ipv6: dnsConfig.ipv6 ?? DEFAULT_DNS_CONFIG.ipv6,
|
||||
fakeIpFilter:
|
||||
dnsConfig["fake-ip-filter"]?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
|
||||
nameserver:
|
||||
dnsConfig.nameserver?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG.nameserver.join(", "),
|
||||
fallback:
|
||||
dnsConfig.fallback?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG.fallback.join(", "),
|
||||
defaultNameserver:
|
||||
dnsConfig["default-nameserver"]?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
|
||||
proxyServerNameserver:
|
||||
dnsConfig["proxy-server-nameserver"]?.join(", ") ??
|
||||
(DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || ""),
|
||||
directNameserver:
|
||||
dnsConfig["direct-nameserver"]?.join(", ") ??
|
||||
(DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || ""),
|
||||
directNameserverFollowPolicy:
|
||||
dnsConfig["direct-nameserver-follow-policy"] ??
|
||||
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"],
|
||||
fallbackGeoip:
|
||||
dnsConfig["fallback-filter"]?.geoip ??
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
|
||||
fallbackGeoipCode:
|
||||
dnsConfig["fallback-filter"]?.["geoip-code"] ??
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
|
||||
fallbackIpcidr:
|
||||
dnsConfig["fallback-filter"]?.ipcidr?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr.join(", "),
|
||||
fallbackDomain:
|
||||
dnsConfig["fallback-filter"]?.domain?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"].domain.join(", "),
|
||||
nameserverPolicy:
|
||||
formatNameserverPolicy(dnsConfig["nameserver-policy"]) || "",
|
||||
hosts: formatHosts(hostsConfig) || "",
|
||||
});
|
||||
};
|
||||
|
||||
// 重置为默认值
|
||||
const resetToDefaults = () => {
|
||||
setValues({
|
||||
enable: DEFAULT_DNS_CONFIG.enable,
|
||||
listen: DEFAULT_DNS_CONFIG.listen,
|
||||
enhancedMode: DEFAULT_DNS_CONFIG["enhanced-mode"],
|
||||
fakeIpRange: DEFAULT_DNS_CONFIG["fake-ip-range"],
|
||||
fakeIpFilterMode: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"],
|
||||
preferH3: DEFAULT_DNS_CONFIG["prefer-h3"],
|
||||
respectRules: DEFAULT_DNS_CONFIG["respect-rules"],
|
||||
useHosts: DEFAULT_DNS_CONFIG["use-hosts"],
|
||||
useSystemHosts: DEFAULT_DNS_CONFIG["use-system-hosts"],
|
||||
ipv6: DEFAULT_DNS_CONFIG.ipv6,
|
||||
fakeIpFilter: DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
|
||||
defaultNameserver: DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
|
||||
nameserver: DEFAULT_DNS_CONFIG.nameserver.join(", "),
|
||||
fallback: DEFAULT_DNS_CONFIG.fallback.join(", "),
|
||||
proxyServerNameserver:
|
||||
DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || "",
|
||||
directNameserver:
|
||||
DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || "",
|
||||
directNameserverFollowPolicy:
|
||||
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"] || false,
|
||||
fallbackGeoip: DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
|
||||
fallbackGeoipCode: DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
|
||||
fallbackIpcidr:
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr?.join(", ") || "",
|
||||
fallbackDomain:
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"].domain?.join(", ") || "",
|
||||
nameserverPolicy: "",
|
||||
hosts: "",
|
||||
});
|
||||
|
||||
// 更新YAML编辑器内容
|
||||
updateYamlFromValues();
|
||||
};
|
||||
|
||||
// 从表单值更新YAML内容
|
||||
const updateYamlFromValues = () => {
|
||||
const config: Record<string, any> = {};
|
||||
|
||||
const dnsConfig = generateDnsConfig();
|
||||
if (Object.keys(dnsConfig).length > 0) {
|
||||
config.dns = dnsConfig;
|
||||
}
|
||||
|
||||
const hosts = parseHosts(values.hosts);
|
||||
if (Object.keys(hosts).length > 0) {
|
||||
config.hosts = hosts;
|
||||
}
|
||||
|
||||
setYamlContent(yaml.dump(config, { forceQuotes: true }));
|
||||
};
|
||||
|
||||
// 从YAML更新表单值
|
||||
const updateValuesFromYaml = () => {
|
||||
try {
|
||||
const parsedYaml = yaml.load(yamlContent) as any;
|
||||
if (!parsedYaml) return;
|
||||
|
||||
updateValuesFromConfig(parsedYaml);
|
||||
} catch {
|
||||
showNotice("error", t("Invalid YAML format"));
|
||||
}
|
||||
};
|
||||
|
||||
// 解析nameserver-policy为对象
|
||||
const parseNameserverPolicy = (str: string): Record<string, any> => {
|
||||
const result: Record<string, any> = {};
|
||||
if (!str) return result;
|
||||
|
||||
// 处理geosite:xxx,yyy格式
|
||||
const ruleRegex = /\s*([^=]+?)\s*=\s*([^,]+)(?:,|$)/g;
|
||||
let match;
|
||||
|
||||
while ((match = ruleRegex.exec(str)) !== null) {
|
||||
const [, domainsPart, serversPart] = match;
|
||||
|
||||
// 处理域名部分
|
||||
let domains;
|
||||
if (domainsPart.startsWith("geosite:")) {
|
||||
domains = [domainsPart.trim()];
|
||||
} else {
|
||||
domains = [domainsPart.trim()];
|
||||
}
|
||||
|
||||
// 处理服务器部分
|
||||
const servers = serversPart.split(";").map((s) => s.trim());
|
||||
|
||||
// 为每个域名组分配相同的服务器列表
|
||||
domains.forEach((domain) => {
|
||||
result[domain] = servers;
|
||||
setValues({
|
||||
enable: dnsConfig.enable ?? DEFAULT_DNS_CONFIG.enable,
|
||||
listen: dnsConfig.listen ?? DEFAULT_DNS_CONFIG.listen,
|
||||
enhancedMode: validEnhancedMode,
|
||||
fakeIpRange:
|
||||
dnsConfig["fake-ip-range"] ?? DEFAULT_DNS_CONFIG["fake-ip-range"],
|
||||
fakeIpFilterMode: validFakeIpFilterMode,
|
||||
preferH3: dnsConfig["prefer-h3"] ?? DEFAULT_DNS_CONFIG["prefer-h3"],
|
||||
respectRules:
|
||||
dnsConfig["respect-rules"] ?? DEFAULT_DNS_CONFIG["respect-rules"],
|
||||
useHosts: dnsConfig["use-hosts"] ?? DEFAULT_DNS_CONFIG["use-hosts"],
|
||||
useSystemHosts:
|
||||
dnsConfig["use-system-hosts"] ??
|
||||
DEFAULT_DNS_CONFIG["use-system-hosts"],
|
||||
ipv6: dnsConfig.ipv6 ?? DEFAULT_DNS_CONFIG.ipv6,
|
||||
fakeIpFilter:
|
||||
dnsConfig["fake-ip-filter"]?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
|
||||
nameserver:
|
||||
dnsConfig.nameserver?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG.nameserver.join(", "),
|
||||
fallback:
|
||||
dnsConfig.fallback?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG.fallback.join(", "),
|
||||
defaultNameserver:
|
||||
dnsConfig["default-nameserver"]?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
|
||||
proxyServerNameserver:
|
||||
dnsConfig["proxy-server-nameserver"]?.join(", ") ??
|
||||
(DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || ""),
|
||||
directNameserver:
|
||||
dnsConfig["direct-nameserver"]?.join(", ") ??
|
||||
(DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || ""),
|
||||
directNameserverFollowPolicy:
|
||||
dnsConfig["direct-nameserver-follow-policy"] ??
|
||||
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"],
|
||||
fallbackGeoip:
|
||||
dnsConfig["fallback-filter"]?.geoip ??
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
|
||||
fallbackGeoipCode:
|
||||
dnsConfig["fallback-filter"]?.["geoip-code"] ??
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
|
||||
fallbackIpcidr:
|
||||
dnsConfig["fallback-filter"]?.ipcidr?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr.join(", "),
|
||||
fallbackDomain:
|
||||
dnsConfig["fallback-filter"]?.domain?.join(", ") ??
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"].domain.join(", "),
|
||||
nameserverPolicy:
|
||||
formatNameserverPolicy(dnsConfig["nameserver-policy"]) || "",
|
||||
hosts: formatHosts(hostsConfig) || "",
|
||||
});
|
||||
}
|
||||
},
|
||||
[setValues],
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 格式化nameserver-policy为字符串
|
||||
const formatNameserverPolicy = (policy: any): string => {
|
||||
if (!policy || typeof policy !== "object") return "";
|
||||
|
||||
// 直接将对象转换为字符串格式
|
||||
return Object.entries(policy)
|
||||
.map(([domain, servers]) => {
|
||||
const serversStr = Array.isArray(servers) ? servers.join(";") : servers;
|
||||
return `${domain}=${serversStr}`;
|
||||
})
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
// 格式化hosts为字符串
|
||||
const formatHosts = (hosts: any): string => {
|
||||
if (!hosts || typeof hosts !== "object") return "";
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
Object.entries(hosts).forEach(([domain, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
// 处理数组格式的IP
|
||||
const ipsStr = value.join(";");
|
||||
result.push(`${domain}=${ipsStr}`);
|
||||
} else {
|
||||
// 处理单个IP或域名
|
||||
result.push(`${domain}=${value}`);
|
||||
}
|
||||
});
|
||||
|
||||
return result.join(", ");
|
||||
};
|
||||
|
||||
// 解析hosts字符串为对象
|
||||
const parseHosts = (str: string): Record<string, any> => {
|
||||
const result: Record<string, any> = {};
|
||||
if (!str) return result;
|
||||
|
||||
str.split(",").forEach((item) => {
|
||||
const parts = item.trim().split("=");
|
||||
if (parts.length < 2) return;
|
||||
|
||||
const domain = parts[0].trim();
|
||||
const valueStr = parts.slice(1).join("=").trim();
|
||||
|
||||
// 检查是否包含多个分号分隔的IP
|
||||
if (valueStr.includes(";")) {
|
||||
result[domain] = valueStr
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
result[domain] = valueStr;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 初始化时设置默认YAML
|
||||
useEffect(() => {
|
||||
updateYamlFromValues();
|
||||
}, []);
|
||||
|
||||
// 切换编辑模式时的处理
|
||||
useEffect(() => {
|
||||
if (visualization) {
|
||||
updateValuesFromYaml();
|
||||
} else {
|
||||
updateYamlFromValues();
|
||||
}
|
||||
}, [visualization]);
|
||||
|
||||
// 解析列表字符串为数组
|
||||
const parseList = (str: string): string[] => {
|
||||
if (!str?.trim()) return [];
|
||||
return str
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
// 生成DNS配置对象
|
||||
const generateDnsConfig = () => {
|
||||
const generateDnsConfig = useCallback(() => {
|
||||
const dnsConfig: any = {
|
||||
enable: values.enable,
|
||||
listen: values.listen,
|
||||
@@ -481,8 +361,132 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
}
|
||||
|
||||
return dnsConfig;
|
||||
};
|
||||
}, [values]);
|
||||
|
||||
const updateYamlFromValues = useCallback(() => {
|
||||
const config: Record<string, any> = {};
|
||||
|
||||
const dnsConfig = generateDnsConfig();
|
||||
if (Object.keys(dnsConfig).length > 0) {
|
||||
config.dns = dnsConfig;
|
||||
}
|
||||
|
||||
const hosts = parseHosts(values.hosts);
|
||||
if (Object.keys(hosts).length > 0) {
|
||||
config.hosts = hosts;
|
||||
}
|
||||
|
||||
setYamlContent(yaml.dump(config, { forceQuotes: true }));
|
||||
}, [generateDnsConfig, setYamlContent, values.hosts]);
|
||||
|
||||
// 重置为默认值
|
||||
const resetToDefaults = useCallback(() => {
|
||||
setValues({
|
||||
enable: DEFAULT_DNS_CONFIG.enable,
|
||||
listen: DEFAULT_DNS_CONFIG.listen,
|
||||
enhancedMode: DEFAULT_DNS_CONFIG["enhanced-mode"],
|
||||
fakeIpRange: DEFAULT_DNS_CONFIG["fake-ip-range"],
|
||||
fakeIpFilterMode: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"],
|
||||
preferH3: DEFAULT_DNS_CONFIG["prefer-h3"],
|
||||
respectRules: DEFAULT_DNS_CONFIG["respect-rules"],
|
||||
useHosts: DEFAULT_DNS_CONFIG["use-hosts"],
|
||||
useSystemHosts: DEFAULT_DNS_CONFIG["use-system-hosts"],
|
||||
ipv6: DEFAULT_DNS_CONFIG.ipv6,
|
||||
fakeIpFilter: DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
|
||||
defaultNameserver: DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
|
||||
nameserver: DEFAULT_DNS_CONFIG.nameserver.join(", "),
|
||||
fallback: DEFAULT_DNS_CONFIG.fallback.join(", "),
|
||||
proxyServerNameserver:
|
||||
DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || "",
|
||||
directNameserver:
|
||||
DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || "",
|
||||
directNameserverFollowPolicy:
|
||||
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"] || false,
|
||||
fallbackGeoip: DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
|
||||
fallbackGeoipCode: DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
|
||||
fallbackIpcidr:
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr?.join(", ") || "",
|
||||
fallbackDomain:
|
||||
DEFAULT_DNS_CONFIG["fallback-filter"].domain?.join(", ") || "",
|
||||
nameserverPolicy: "",
|
||||
hosts: "",
|
||||
});
|
||||
|
||||
updateYamlFromValues();
|
||||
}, [setValues, updateYamlFromValues]);
|
||||
|
||||
// 从YAML更新表单值
|
||||
const updateValuesFromYaml = useCallback(() => {
|
||||
try {
|
||||
const parsedYaml = yaml.load(yamlContent) as any;
|
||||
if (!parsedYaml) return;
|
||||
|
||||
skipYamlSyncRef.current = true;
|
||||
updateValuesFromConfig(parsedYaml);
|
||||
} catch {
|
||||
showNotice("error", t("Invalid YAML format"));
|
||||
}
|
||||
}, [yamlContent, t, updateValuesFromConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipYamlSyncRef.current) {
|
||||
skipYamlSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
updateYamlFromValues();
|
||||
}, [updateYamlFromValues]);
|
||||
|
||||
const latestUpdateValuesFromYamlRef = useRef(updateValuesFromYaml);
|
||||
const latestUpdateYamlFromValuesRef = useRef(updateYamlFromValues);
|
||||
|
||||
useEffect(() => {
|
||||
latestUpdateValuesFromYamlRef.current = updateValuesFromYaml;
|
||||
latestUpdateYamlFromValuesRef.current = updateYamlFromValues;
|
||||
}, [updateValuesFromYaml, updateYamlFromValues]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visualization) {
|
||||
latestUpdateValuesFromYamlRef.current();
|
||||
} else {
|
||||
latestUpdateYamlFromValuesRef.current();
|
||||
}
|
||||
}, [visualization]);
|
||||
|
||||
const initDnsConfig = useCallback(async () => {
|
||||
try {
|
||||
const dnsConfigExists = await invoke<boolean>(
|
||||
"check_dns_config_exists",
|
||||
{},
|
||||
);
|
||||
|
||||
if (dnsConfigExists) {
|
||||
const dnsConfig = await invoke<string>("get_dns_config_content", {});
|
||||
const config = yaml.load(dnsConfig) as any;
|
||||
|
||||
updateValuesFromConfig(config);
|
||||
setYamlContent(dnsConfig);
|
||||
} else {
|
||||
resetToDefaults();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize DNS config", err);
|
||||
resetToDefaults();
|
||||
}
|
||||
}, [resetToDefaults, setYamlContent, updateValuesFromConfig]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open: () => {
|
||||
setOpen(true);
|
||||
void initDnsConfig();
|
||||
},
|
||||
close: () => setOpen(false),
|
||||
}),
|
||||
[initDnsConfig],
|
||||
);
|
||||
|
||||
// 生成DNS配置对象
|
||||
// 处理保存操作
|
||||
const onSave = useLockFn(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Delete as DeleteIcon } from "@mui/icons-material";
|
||||
import { Box, Button, Divider, List, ListItem, TextField } from "@mui/material";
|
||||
import { useLockFn, useRequest } from "ahooks";
|
||||
import { forwardRef, useImperativeHandle, useState } from "react";
|
||||
import { forwardRef, useImperativeHandle, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BaseDialog, Switch } from "@/components/base";
|
||||
@@ -165,6 +165,19 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
|
||||
await saveConfig();
|
||||
});
|
||||
|
||||
const originEntries = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
return corsConfig.allowOrigins.map((origin, index) => {
|
||||
const occurrence = (counts[origin] = (counts[origin] ?? 0) + 1);
|
||||
const keyBase = origin || "origin";
|
||||
return {
|
||||
origin,
|
||||
index,
|
||||
key: `${keyBase}-${occurrence}`,
|
||||
};
|
||||
});
|
||||
}, [corsConfig.allowOrigins]);
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
open={open}
|
||||
@@ -207,9 +220,9 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
|
||||
<div style={{ marginBottom: 8, fontWeight: "bold" }}>
|
||||
{t("Allowed Origins")}
|
||||
</div>
|
||||
{corsConfig.allowOrigins.map((origin, index) => (
|
||||
{originEntries.map(({ origin, index, key }) => (
|
||||
<div
|
||||
key={index}
|
||||
key={key}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cloneElement, isValidElement, ReactNode, useRef } from "react";
|
||||
import { createElement, isValidElement, ReactNode, useRef } from "react";
|
||||
|
||||
import noop from "@/utils/noop";
|
||||
|
||||
@@ -24,7 +24,7 @@ export function GuardState<T>(props: Props<T>) {
|
||||
onGuard = noop,
|
||||
onCatch = noop,
|
||||
onChange = noop,
|
||||
onFormat = (v: T) => v,
|
||||
onFormat,
|
||||
} = props;
|
||||
|
||||
const lockRef = useRef(false);
|
||||
@@ -45,7 +45,7 @@ export function GuardState<T>(props: Props<T>) {
|
||||
lockRef.current = true;
|
||||
|
||||
try {
|
||||
const newValue = (onFormat as any)(...args);
|
||||
const newValue = onFormat ? (onFormat as any)(...args) : (args[0] as T);
|
||||
// 先在ui上响应操作
|
||||
onChange(newValue);
|
||||
|
||||
@@ -81,5 +81,7 @@ export function GuardState<T>(props: Props<T>) {
|
||||
}
|
||||
lockRef.current = false;
|
||||
};
|
||||
return cloneElement(children, childProps);
|
||||
const { children: nestedChildren, ...restProps } = childProps;
|
||||
|
||||
return createElement(children.type, restProps, nestedChildren);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
const { verge, patchVerge } = useVerge();
|
||||
|
||||
const [hotkeyMap, setHotkeyMap] = useState<Record<string, string[]>>({});
|
||||
const [enableGlobalHotkey, setEnableHotkey] = useState(
|
||||
const [enableGlobalHotkey, setEnableGlobalHotkey] = useState(
|
||||
verge?.enable_global_hotkey ?? true,
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
<Switch
|
||||
edge="end"
|
||||
checked={enableGlobalHotkey}
|
||||
onChange={(e) => setEnableHotkey(e.target.checked)}
|
||||
onChange={(e) => setEnableGlobalHotkey(e.target.checked)}
|
||||
/>
|
||||
</ItemWrapper>
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
valueProps="checked"
|
||||
onCatch={onError}
|
||||
onFormat={onSwitchFormat}
|
||||
onChange={async (e) => {
|
||||
onChange={async () => {
|
||||
await toggleDecorations();
|
||||
}}
|
||||
>
|
||||
@@ -198,8 +198,8 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
value={verge?.menu_icon ?? "monochrome"}
|
||||
onCatch={onError}
|
||||
onFormat={(e: any) => e.target.value}
|
||||
onChange={(e) => onChangeData({ menu_icon: e })}
|
||||
onGuard={(e) => patchVerge({ menu_icon: e })}
|
||||
onChange={(value) => onChangeData({ menu_icon: value })}
|
||||
onGuard={(value) => patchVerge({ menu_icon: value })}
|
||||
>
|
||||
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
|
||||
<MenuItem value="monochrome">{t("Monochrome")}</MenuItem>
|
||||
|
||||
@@ -102,7 +102,13 @@ export function NetworkInterfaceViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
);
|
||||
}
|
||||
|
||||
const AddressDisplay = (props: { label: string; content: string }) => {
|
||||
const AddressDisplay = ({
|
||||
label,
|
||||
content,
|
||||
}: {
|
||||
label: string;
|
||||
content: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -113,7 +119,7 @@ const AddressDisplay = (props: { label: string; content: string }) => {
|
||||
margin: "8px 0",
|
||||
}}
|
||||
>
|
||||
<Box>{props.label}</Box>
|
||||
<Box>{label}</Box>
|
||||
<Box
|
||||
sx={({ palette }) => ({
|
||||
borderRadius: "8px",
|
||||
@@ -124,13 +130,11 @@ const AddressDisplay = (props: { label: string; content: string }) => {
|
||||
: alpha(palette.grey[400], 0.3),
|
||||
})}
|
||||
>
|
||||
<Box sx={{ display: "inline", userSelect: "text" }}>
|
||||
{props.content}
|
||||
</Box>
|
||||
<Box sx={{ display: "inline", userSelect: "text" }}>{content}</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
await writeText(props.content);
|
||||
await writeText(content);
|
||||
showNotice("success", t("Copy Success"));
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DialogTitle,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
@@ -19,10 +19,6 @@ export const PasswordInput = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [passwd, setPasswd] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={true} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t("Please enter your root password")}</DialogTitle>
|
||||
|
||||
@@ -20,8 +20,13 @@ interface ItemProps {
|
||||
onClick?: () => void | Promise<any>;
|
||||
}
|
||||
|
||||
export const SettingItem: React.FC<ItemProps> = (props) => {
|
||||
const { label, extra, children, secondary, onClick } = props;
|
||||
export const SettingItem: React.FC<ItemProps> = ({
|
||||
label,
|
||||
extra,
|
||||
children,
|
||||
secondary,
|
||||
onClick,
|
||||
}) => {
|
||||
const clickable = !!onClick;
|
||||
|
||||
const primary = (
|
||||
@@ -65,7 +70,7 @@ export const SettingItem: React.FC<ItemProps> = (props) => {
|
||||
export const SettingList: React.FC<{
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}> = (props) => (
|
||||
}> = ({ title, children }) => (
|
||||
<List>
|
||||
<ListSubheader
|
||||
sx={[
|
||||
@@ -78,9 +83,9 @@ export const SettingList: React.FC<{
|
||||
]}
|
||||
disableSticky
|
||||
>
|
||||
{props.title}
|
||||
{title}
|
||||
</ListSubheader>
|
||||
|
||||
{props.children}
|
||||
{children}
|
||||
</List>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -38,6 +39,11 @@ import {
|
||||
import { showNotice } from "@/services/noticeService";
|
||||
import getSystem from "@/utils/get-system";
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const DEFAULT_PAC = `function FindProxyForURL(url, host) {
|
||||
return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;";
|
||||
}`;
|
||||
@@ -130,40 +136,37 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
errorRetryInterval: 5000,
|
||||
});
|
||||
|
||||
const [prevMixedPort, setPrevMixedPort] = useState(clashConfig?.mixedPort);
|
||||
const prevMixedPortRef = useRef(clashConfig?.mixedPort);
|
||||
|
||||
useEffect(() => {
|
||||
if (clashConfig?.mixedPort && clashConfig.mixedPort !== prevMixedPort) {
|
||||
setPrevMixedPort(clashConfig.mixedPort);
|
||||
resetSystemProxy();
|
||||
const mixedPort = clashConfig?.mixedPort;
|
||||
if (!mixedPort || mixedPort === prevMixedPortRef.current) {
|
||||
return;
|
||||
}
|
||||
}, [clashConfig?.mixedPort]);
|
||||
|
||||
const resetSystemProxy = async () => {
|
||||
try {
|
||||
const currentSysProxy = await getSystemProxy();
|
||||
const currentAutoProxy = await getAutotemProxy();
|
||||
prevMixedPortRef.current = mixedPort;
|
||||
|
||||
if (value.pac ? currentAutoProxy?.enable : currentSysProxy?.enable) {
|
||||
// 临时关闭系统代理
|
||||
await patchVergeConfig({ enable_system_proxy: false });
|
||||
const updateProxy = async () => {
|
||||
try {
|
||||
const currentSysProxy = await getSystemProxy();
|
||||
const currentAutoProxy = await getAutotemProxy();
|
||||
|
||||
// 减少等待时间
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// 重新开启系统代理
|
||||
await patchVergeConfig({ enable_system_proxy: true });
|
||||
|
||||
// 更新UI状态
|
||||
await Promise.all([
|
||||
mutate("getSystemProxy"),
|
||||
mutate("getAutotemProxy"),
|
||||
]);
|
||||
if (value.pac ? currentAutoProxy?.enable : currentSysProxy?.enable) {
|
||||
await patchVergeConfig({ enable_system_proxy: false });
|
||||
await sleep(200);
|
||||
await patchVergeConfig({ enable_system_proxy: true });
|
||||
await Promise.all([
|
||||
mutate("getSystemProxy"),
|
||||
mutate("getAutotemProxy"),
|
||||
]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
showNotice("error", err.toString());
|
||||
}
|
||||
} catch (err: any) {
|
||||
showNotice("error", err.toString());
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
updateProxy();
|
||||
}, [clashConfig?.mixedPort, value.pac]);
|
||||
|
||||
const { systemProxyAddress } = useAppData();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
@@ -38,6 +38,14 @@ export const WebUIItem = (props: Props) => {
|
||||
const [editValue, setEditValue] = useState(value);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const highlightedParts = useMemo(() => {
|
||||
const placeholderRegex = /(%host|%port|%secret)/g;
|
||||
if (!value) {
|
||||
return ["NULL"];
|
||||
}
|
||||
return value.split(placeholderRegex).filter((part) => part !== "");
|
||||
}, [value]);
|
||||
|
||||
if (editing || onlyEdit) {
|
||||
return (
|
||||
<>
|
||||
@@ -78,10 +86,26 @@ export const WebUIItem = (props: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const html = value
|
||||
?.replace("%host", "<span>%host</span>")
|
||||
.replace("%port", "<span>%port</span>")
|
||||
.replace("%secret", "<span>%secret</span>");
|
||||
const placeholderCounts: Record<string, number> = {};
|
||||
let textCounter = 0;
|
||||
const renderedParts = highlightedParts.map((part) => {
|
||||
const isPlaceholder =
|
||||
part === "%host" || part === "%port" || part === "%secret";
|
||||
|
||||
if (isPlaceholder) {
|
||||
const count = placeholderCounts[part] ?? 0;
|
||||
placeholderCounts[part] = count + 1;
|
||||
return (
|
||||
<span key={`placeholder-${part}-${count}`} className="placeholder">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const key = `text-${textCounter}-${part || "empty"}`;
|
||||
textCounter += 1;
|
||||
return <span key={key}>{part}</span>;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -94,12 +118,13 @@ export const WebUIItem = (props: Props) => {
|
||||
sx={({ palette }) => ({
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
"> span": {
|
||||
"> .placeholder": {
|
||||
color: palette.primary.main,
|
||||
},
|
||||
})}
|
||||
dangerouslySetInnerHTML={{ __html: html || "NULL" }}
|
||||
/>
|
||||
>
|
||||
{renderedParts}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
title={t("Open URL")}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
import { useLockFn } from "ahooks";
|
||||
import type { Ref } from "react";
|
||||
import { useImperativeHandle, useState } from "react";
|
||||
import { useImperativeHandle, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BaseDialog, BaseEmpty, DialogRef } from "@/components/base";
|
||||
@@ -12,6 +12,12 @@ import { showNotice } from "@/services/noticeService";
|
||||
|
||||
import { WebUIItem } from "./web-ui-item";
|
||||
|
||||
const DEFAULT_WEB_UI_LIST = [
|
||||
"https://metacubex.github.io/metacubexd/#/setup?http=true&hostname=%host&port=%port&secret=%secret",
|
||||
"https://yacd.metacubex.one/?hostname=%host&port=%port&secret=%secret",
|
||||
"https://board.zash.run.place/#/setup?http=true&hostname=%host&port=%port&secret=%secret",
|
||||
];
|
||||
|
||||
export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -26,11 +32,21 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
close: () => setOpen(false),
|
||||
}));
|
||||
|
||||
const webUIList = verge?.web_ui_list || [
|
||||
"https://metacubex.github.io/metacubexd/#/setup?http=true&hostname=%host&port=%port&secret=%secret",
|
||||
"https://yacd.metacubex.one/?hostname=%host&port=%port&secret=%secret",
|
||||
"https://board.zash.run.place/#/setup?http=true&hostname=%host&port=%port&secret=%secret",
|
||||
];
|
||||
const webUIList = verge?.web_ui_list || DEFAULT_WEB_UI_LIST;
|
||||
|
||||
const webUIEntries = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
return webUIList.map((item, index) => {
|
||||
const keyBase = item && item.trim().length > 0 ? item : "entry";
|
||||
const count = counts[keyBase] ?? 0;
|
||||
counts[keyBase] = count + 1;
|
||||
return {
|
||||
item,
|
||||
index,
|
||||
key: `${keyBase}-${count}`,
|
||||
};
|
||||
});
|
||||
}, [webUIList]);
|
||||
|
||||
const handleAdd = useLockFn(async (value: string) => {
|
||||
const newList = [...webUIList, value];
|
||||
@@ -118,9 +134,9 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{webUIList.map((item, index) => (
|
||||
{webUIEntries.map(({ item, index, key }) => (
|
||||
<WebUIItem
|
||||
key={index}
|
||||
key={key}
|
||||
value={item}
|
||||
onChange={(v) => handleChange(index, v)}
|
||||
onDelete={() => handleDelete(index)}
|
||||
|
||||
@@ -23,8 +23,12 @@ interface Props {
|
||||
onDelete: (uid: string) => void;
|
||||
}
|
||||
|
||||
export const TestItem = (props: Props) => {
|
||||
const { itemData, onEdit, onDelete: onDeleteItem } = props;
|
||||
export const TestItem = ({
|
||||
id,
|
||||
itemData,
|
||||
onEdit,
|
||||
onDelete: removeTest,
|
||||
}: Props) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -33,7 +37,7 @@ export const TestItem = (props: Props) => {
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: props.id,
|
||||
id,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -50,17 +54,19 @@ export const TestItem = (props: Props) => {
|
||||
setDelay(result);
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
initIconCachePath();
|
||||
}, [icon]);
|
||||
|
||||
async function initIconCachePath() {
|
||||
const initIconCachePath = useCallback(async () => {
|
||||
if (icon && icon.trim().startsWith("http")) {
|
||||
const fileName = uid + "-" + getFileName(icon);
|
||||
const iconPath = await downloadIconCache(icon, fileName);
|
||||
setIconCachePath(convertFileSrc(iconPath));
|
||||
} else {
|
||||
setIconCachePath("");
|
||||
}
|
||||
}
|
||||
}, [icon, uid]);
|
||||
|
||||
useEffect(() => {
|
||||
void initIconCachePath();
|
||||
}, [initIconCachePath]);
|
||||
|
||||
function getFileName(url: string) {
|
||||
return url.substring(url.lastIndexOf("/") + 1);
|
||||
@@ -74,7 +80,7 @@ export const TestItem = (props: Props) => {
|
||||
const onDelete = useLockFn(async () => {
|
||||
setAnchorEl(null);
|
||||
try {
|
||||
onDeleteItem(uid);
|
||||
removeTest(uid);
|
||||
} catch (err: any) {
|
||||
showNotice("error", err.message || err.toString());
|
||||
}
|
||||
@@ -102,12 +108,12 @@ export const TestItem = (props: Props) => {
|
||||
return () => {
|
||||
if (unlistenFn) {
|
||||
console.log(
|
||||
`TestItem for ${props.id} unmounting or url changed, cleaning up test-all listener.`,
|
||||
`TestItem for ${id} unmounting or url changed, cleaning up test-all listener.`,
|
||||
);
|
||||
unlistenFn();
|
||||
}
|
||||
};
|
||||
}, [url, addListener, onDelay, props.id]);
|
||||
}, [url, addListener, onDelay, id]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -26,12 +26,7 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { verge, patchVerge } = useVerge();
|
||||
const testList = verge?.test_list ?? [];
|
||||
const {
|
||||
control,
|
||||
watch: _watch,
|
||||
register: _register,
|
||||
...formIns
|
||||
} = useForm<IVergeTestItem>({
|
||||
const { control, ...formIns } = useForm<IVergeTestItem>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
icon: "",
|
||||
|
||||
@@ -133,11 +133,22 @@ export const useLogData = () => {
|
||||
mutate(`$sub$${subscriptKey}`);
|
||||
}, [date, subscriptKey]);
|
||||
|
||||
const previousLogLevel = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logLevel) return;
|
||||
if (!logLevel) {
|
||||
previousLogLevel.current = logLevel ?? undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousLogLevel.current === logLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousLogLevel.current = logLevel;
|
||||
ws.current?.close();
|
||||
setDate(Date.now());
|
||||
}, [logLevel]);
|
||||
}, [logLevel, setDate]);
|
||||
|
||||
const refreshGetClashLog = (clear = false) => {
|
||||
if (clear) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useEffect, useRef, useCallback, useReducer } from "react";
|
||||
|
||||
// import { useClashInfo } from "@/hooks/use-clash";
|
||||
// import { useVisibility } from "@/hooks/use-visibility";
|
||||
@@ -196,12 +196,11 @@ export const useTrafficMonitorEnhanced = () => {
|
||||
});
|
||||
}
|
||||
|
||||
const [, forceUpdate] = useState({});
|
||||
const [, forceRender] = useReducer((version: number) => version + 1, 0);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// 强制组件更新
|
||||
const triggerUpdate = useCallback(() => {
|
||||
forceUpdate({});
|
||||
const bumpRenderVersion = useCallback(() => {
|
||||
forceRender();
|
||||
}, []);
|
||||
|
||||
// 注册引用计数
|
||||
@@ -250,9 +249,8 @@ export const useTrafficMonitorEnhanced = () => {
|
||||
}),
|
||||
};
|
||||
globalSampler.addDataPoint(dataPoint);
|
||||
triggerUpdate();
|
||||
}
|
||||
}, [traffic, triggerUpdate]);
|
||||
}, [traffic]);
|
||||
|
||||
// const { data: monitorData, error } = useSWR<ISystemMonitorOverview>(
|
||||
// shouldFetch ? "getSystemMonitorOverviewSafe" : null,
|
||||
@@ -328,9 +326,9 @@ export const useTrafficMonitorEnhanced = () => {
|
||||
const clearData = useCallback(() => {
|
||||
if (globalSampler) {
|
||||
globalSampler.clear();
|
||||
triggerUpdate();
|
||||
bumpRenderVersion();
|
||||
}
|
||||
}, [triggerUpdate]);
|
||||
}, [bumpRenderVersion]);
|
||||
|
||||
// 获取采样器统计信息
|
||||
const getSamplerStats = useCallback(() => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const useVisibility = () => {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [visible, setVisible] = useState(() =>
|
||||
typeof document === "undefined"
|
||||
? true
|
||||
: document.visibilityState === "visible",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
@@ -9,16 +13,15 @@ export const useVisibility = () => {
|
||||
};
|
||||
|
||||
const handleFocus = () => setVisible(true);
|
||||
const handleClick = () => setVisible(true);
|
||||
const handlePointerDown = () => setVisible(true);
|
||||
|
||||
handleVisibilityChange();
|
||||
document.addEventListener("focus", handleFocus);
|
||||
document.addEventListener("pointerdown", handleClick);
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("focus", handleFocus);
|
||||
document.removeEventListener("pointerdown", handleClick);
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
46
src/hooks/use-window.ts
Normal file
46
src/hooks/use-window.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { use } from "react";
|
||||
|
||||
import { WindowContext, type WindowContextType } from "@/providers/window";
|
||||
|
||||
export const useWindow = () => {
|
||||
const context = use(WindowContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useWindow must be used within WindowProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useWindowControls = () => {
|
||||
const {
|
||||
maximized,
|
||||
minimize,
|
||||
toggleMaximize,
|
||||
close,
|
||||
toggleFullscreen,
|
||||
currentWindow,
|
||||
} = useWindow();
|
||||
return {
|
||||
maximized,
|
||||
minimize,
|
||||
toggleMaximize,
|
||||
close,
|
||||
toggleFullscreen,
|
||||
currentWindow,
|
||||
} satisfies Pick<
|
||||
WindowContextType,
|
||||
| "maximized"
|
||||
| "minimize"
|
||||
| "toggleMaximize"
|
||||
| "close"
|
||||
| "toggleFullscreen"
|
||||
| "currentWindow"
|
||||
>;
|
||||
};
|
||||
|
||||
export const useWindowDecorations = () => {
|
||||
const { decorated, toggleDecorations, refreshDecorated } = useWindow();
|
||||
return { decorated, toggleDecorations, refreshDecorated } satisfies Pick<
|
||||
WindowContextType,
|
||||
"decorated" | "toggleDecorations" | "refreshDecorated"
|
||||
>;
|
||||
};
|
||||
@@ -1,129 +0,0 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { debounce } from "lodash-es";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
use,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface WindowContextType {
|
||||
decorated: boolean | null;
|
||||
maximized: boolean | null;
|
||||
toggleDecorations: () => Promise<void>;
|
||||
refreshDecorated: () => Promise<boolean>;
|
||||
minimize: () => void;
|
||||
close: () => void;
|
||||
toggleMaximize: () => Promise<void>;
|
||||
toggleFullscreen: () => Promise<void>;
|
||||
currentWindow: ReturnType<typeof getCurrentWindow>;
|
||||
}
|
||||
|
||||
const WindowContext = createContext<WindowContextType | undefined>(undefined);
|
||||
|
||||
export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const currentWindow = getCurrentWindow();
|
||||
const [decorated, setDecorated] = useState<boolean | null>(null);
|
||||
const [maximized, setMaximized] = useState<boolean | null>(null);
|
||||
|
||||
const close = useCallback(() => currentWindow.close(), [currentWindow]);
|
||||
const minimize = useCallback(() => currentWindow.minimize(), [currentWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMaximized = debounce(async () => {
|
||||
const value = await currentWindow.isMaximized();
|
||||
if (maximized !== value) {
|
||||
setMaximized(value);
|
||||
}
|
||||
}, 100);
|
||||
const unlistenResize = currentWindow.onResized(checkMaximized);
|
||||
|
||||
return () => {
|
||||
unlistenResize.then((fn) => fn());
|
||||
};
|
||||
}, [currentWindow, maximized]);
|
||||
|
||||
const toggleMaximize = useCallback(async () => {
|
||||
if (await currentWindow.isMaximized()) {
|
||||
await currentWindow.unmaximize();
|
||||
setMaximized(false);
|
||||
} else {
|
||||
await currentWindow.maximize();
|
||||
setMaximized(true);
|
||||
}
|
||||
}, [currentWindow]);
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
await currentWindow.setFullscreen(!(await currentWindow.isFullscreen()));
|
||||
}, [currentWindow]);
|
||||
|
||||
const refreshDecorated = useCallback(async () => {
|
||||
const val = await currentWindow.isDecorated();
|
||||
setDecorated(val);
|
||||
return val;
|
||||
}, [currentWindow]);
|
||||
|
||||
const toggleDecorations = useCallback(async () => {
|
||||
const currentVal = await currentWindow.isDecorated();
|
||||
await currentWindow.setDecorations(!currentVal);
|
||||
setDecorated(!currentVal);
|
||||
}, [currentWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshDecorated();
|
||||
currentWindow.setMinimizable?.(true);
|
||||
}, [currentWindow, refreshDecorated]);
|
||||
|
||||
return (
|
||||
<WindowContext
|
||||
value={{
|
||||
decorated,
|
||||
maximized,
|
||||
toggleDecorations,
|
||||
refreshDecorated,
|
||||
minimize,
|
||||
close,
|
||||
toggleMaximize,
|
||||
toggleFullscreen,
|
||||
currentWindow,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WindowContext>
|
||||
);
|
||||
};
|
||||
|
||||
export const useWindow = () => {
|
||||
const context = use(WindowContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useWindow must be used within WindowProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useWindowControls = () => {
|
||||
const {
|
||||
maximized,
|
||||
minimize,
|
||||
toggleMaximize,
|
||||
close,
|
||||
toggleFullscreen,
|
||||
currentWindow,
|
||||
} = useWindow();
|
||||
return {
|
||||
maximized,
|
||||
minimize,
|
||||
toggleMaximize,
|
||||
close,
|
||||
toggleFullscreen,
|
||||
currentWindow,
|
||||
};
|
||||
};
|
||||
|
||||
export const useWindowDecorations = () => {
|
||||
const { decorated, toggleDecorations, refreshDecorated } = useWindow();
|
||||
return { decorated, toggleDecorations, refreshDecorated };
|
||||
};
|
||||
@@ -10,9 +10,9 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { MihomoWebSocket } from "tauri-plugin-mihomo-api";
|
||||
|
||||
import { BaseErrorBoundary } from "./components/base";
|
||||
import { WindowProvider } from "./hooks/use-window";
|
||||
import Layout from "./pages/_layout";
|
||||
import { AppDataProvider } from "./providers/app-data-provider";
|
||||
import { WindowProvider } from "./providers/window";
|
||||
import { initializeLanguage } from "./services/i18n";
|
||||
import {
|
||||
LoadingCacheProvider,
|
||||
|
||||
@@ -4,13 +4,7 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate, useRoutes } from "react-router-dom";
|
||||
import { SWRConfig, mutate } from "swr";
|
||||
@@ -19,11 +13,11 @@ import iconDark from "@/assets/image/icon_dark.svg?react";
|
||||
import iconLight from "@/assets/image/icon_light.svg?react";
|
||||
import LogoSvg from "@/assets/image/logo.svg?react";
|
||||
import { NoticeManager } from "@/components/base/NoticeManager";
|
||||
import { WindowControls } from "@/components/controller/window-controller";
|
||||
import { LayoutItem } from "@/components/layout/layout-item";
|
||||
import { LayoutTraffic } from "@/components/layout/layout-traffic";
|
||||
import { UpdateButton } from "@/components/layout/update-button";
|
||||
import { useCustomTheme } from "@/components/layout/use-custom-theme";
|
||||
import { useClashInfo } from "@/hooks/use-clash";
|
||||
import { useConnectionData } from "@/hooks/use-connection-data";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useListen } from "@/hooks/use-listen";
|
||||
@@ -34,7 +28,7 @@ import { useVerge } from "@/hooks/use-verge";
|
||||
import { useWindowDecorations } from "@/hooks/use-window";
|
||||
import { getAxios } from "@/services/api";
|
||||
import { showNotice } from "@/services/noticeService";
|
||||
import { useClashLog, useThemeMode } from "@/services/states";
|
||||
import { useThemeMode } from "@/services/states";
|
||||
import getSystem from "@/utils/get-system";
|
||||
|
||||
import { routers } from "./_routers";
|
||||
@@ -42,9 +36,6 @@ import { routers } from "./_routers";
|
||||
import "dayjs/locale/ru";
|
||||
import "dayjs/locale/zh-cn";
|
||||
|
||||
import { WindowControls } from "@/components/controller/window-controller";
|
||||
// 删除重复导入
|
||||
|
||||
const appWindow = getCurrentWebviewWindow();
|
||||
export const portableFlag = false;
|
||||
|
||||
@@ -170,19 +161,25 @@ const Layout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useCustomTheme();
|
||||
const { verge } = useVerge();
|
||||
const { clashInfo } = useClashInfo();
|
||||
const [clashLog] = useClashLog();
|
||||
const enableLog = clashLog.enable;
|
||||
const logLevel = clashLog.logLevel;
|
||||
// const [logLevel] = useLocalStorage<LogLevel>("log:log-level", "info");
|
||||
const { language, start_page } = verge ?? {};
|
||||
const { switchLanguage } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const routersEles = useRoutes(routers);
|
||||
const matchedElement = useRoutes(routers);
|
||||
const routersEles = useMemo(() => {
|
||||
if (!matchedElement) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={location.pathname}>{matchedElement}</React.Fragment>
|
||||
);
|
||||
}, [matchedElement, location.pathname]);
|
||||
const { addListener } = useListen();
|
||||
const initRef = useRef(false);
|
||||
const [themeReady, setThemeReady] = useState(false);
|
||||
const overlayRemovedRef = useRef(false);
|
||||
const lastStartPageRef = useRef<string | null>(null);
|
||||
const startPageAppliedRef = useRef(false);
|
||||
const themeReady = useMemo(() => Boolean(theme), [theme]);
|
||||
|
||||
const windowControls = useRef<any>(null);
|
||||
const { decorated } = useWindowDecorations();
|
||||
@@ -207,33 +204,102 @@ const Layout = () => {
|
||||
}, [decorated, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
setThemeReady(true);
|
||||
}, [theme]);
|
||||
if (!themeReady || overlayRemovedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fadeTimer: number | null = null;
|
||||
let retryTimer: number | null = null;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50;
|
||||
let stopped = false;
|
||||
|
||||
const tryRemoveOverlay = () => {
|
||||
if (stopped || overlayRemovedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = document.getElementById("initial-loading-overlay");
|
||||
if (overlay) {
|
||||
overlayRemovedRef.current = true;
|
||||
overlay.style.opacity = "0";
|
||||
overlay.style.pointerEvents = "none";
|
||||
|
||||
fadeTimer = window.setTimeout(() => {
|
||||
try {
|
||||
overlay.remove();
|
||||
} catch (error) {
|
||||
console.warn("[Layout] Failed to remove loading overlay:", error);
|
||||
}
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempts < maxAttempts) {
|
||||
attempts += 1;
|
||||
retryTimer = window.setTimeout(tryRemoveOverlay, 100);
|
||||
} else {
|
||||
console.warn("[Layout] Loading overlay not found after retries");
|
||||
}
|
||||
};
|
||||
|
||||
tryRemoveOverlay();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (fadeTimer) {
|
||||
window.clearTimeout(fadeTimer);
|
||||
}
|
||||
if (retryTimer) {
|
||||
window.clearTimeout(retryTimer);
|
||||
}
|
||||
};
|
||||
}, [themeReady]);
|
||||
|
||||
const handleNotice = useCallback(
|
||||
(payload: [string, string]) => {
|
||||
const [status, msg] = payload;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
handleNoticeMessage(status, msg, t, navigate);
|
||||
} catch (error) {
|
||||
console.error("[Layout] 处理通知消息失败:", error);
|
||||
}
|
||||
}, 0);
|
||||
try {
|
||||
handleNoticeMessage(status, msg, t, navigate);
|
||||
} catch (error) {
|
||||
console.error("[Layout] 处理通知消息失败:", error);
|
||||
}
|
||||
},
|
||||
[t, navigate],
|
||||
);
|
||||
|
||||
// 初始化全局日志服务
|
||||
// useEffect(() => {
|
||||
// if (clashInfo) {
|
||||
// initGlobalLogService(enableLog, logLevel);
|
||||
// }
|
||||
// }, [clashInfo, enableLog, logLevel]);
|
||||
|
||||
// 设置监听器
|
||||
// 设置监听
|
||||
useEffect(() => {
|
||||
const listeners = [
|
||||
const unlisteners: Array<() => void> = [];
|
||||
let disposed = false;
|
||||
|
||||
const register = (
|
||||
maybeUnlisten: void | (() => void) | Promise<void | (() => void)>,
|
||||
) => {
|
||||
if (!maybeUnlisten) {
|
||||
return;
|
||||
}
|
||||
if (typeof maybeUnlisten === "function") {
|
||||
unlisteners.push(maybeUnlisten);
|
||||
return;
|
||||
}
|
||||
maybeUnlisten
|
||||
.then((unlisten) => {
|
||||
if (!unlisten) {
|
||||
return;
|
||||
}
|
||||
if (disposed) {
|
||||
unlisten();
|
||||
} else {
|
||||
unlisteners.push(unlisten);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Layout] 注册事件监听失败", error);
|
||||
});
|
||||
};
|
||||
|
||||
register(
|
||||
addListener("verge://refresh-clash-config", async () => {
|
||||
await getAxios(true);
|
||||
mutate("getProxies");
|
||||
@@ -241,67 +307,48 @@ const Layout = () => {
|
||||
mutate("getClashConfig");
|
||||
mutate("getProxyProviders");
|
||||
}),
|
||||
);
|
||||
|
||||
register(
|
||||
addListener("verge://refresh-verge-config", () => {
|
||||
mutate("getVergeConfig");
|
||||
mutate("getSystemProxy");
|
||||
mutate("getAutotemProxy");
|
||||
// 运行模式变更时也需要刷新相关状态
|
||||
mutate("getRunningMode");
|
||||
mutate("isServiceAvailable");
|
||||
}),
|
||||
);
|
||||
|
||||
register(
|
||||
addListener("verge://notice-message", ({ payload }) =>
|
||||
handleNotice(payload as [string, string]),
|
||||
),
|
||||
];
|
||||
);
|
||||
|
||||
const setupWindowListeners = async () => {
|
||||
const [hideUnlisten, showUnlisten] = await Promise.all([
|
||||
listen("verge://hide-window", () => appWindow.hide()),
|
||||
listen("verge://show-window", () => appWindow.show()),
|
||||
]);
|
||||
|
||||
return () => {
|
||||
hideUnlisten();
|
||||
showUnlisten();
|
||||
};
|
||||
};
|
||||
|
||||
const cleanupWindow = setupWindowListeners();
|
||||
register(
|
||||
(async () => {
|
||||
const [hideUnlisten, showUnlisten] = await Promise.all([
|
||||
listen("verge://hide-window", () => appWindow.hide()),
|
||||
listen("verge://show-window", () => appWindow.show()),
|
||||
]);
|
||||
return () => {
|
||||
hideUnlisten();
|
||||
showUnlisten();
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
return () => {
|
||||
setTimeout(() => {
|
||||
listeners.forEach((listener) => {
|
||||
if (typeof listener.then === "function") {
|
||||
listener
|
||||
.then((unlisten) => {
|
||||
try {
|
||||
unlisten();
|
||||
} catch (error) {
|
||||
console.error("[Layout] 清理事件监听器失败:", error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Layout] 获取unlisten函数失败:", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
cleanupWindow
|
||||
.then((cleanup) => {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
console.error("[Layout] 清理窗口监听器失败:", error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Layout] 获取cleanup函数失败:", error);
|
||||
});
|
||||
}, 0);
|
||||
disposed = true;
|
||||
unlisteners.forEach((unlisten) => {
|
||||
try {
|
||||
unlisten();
|
||||
} catch (error) {
|
||||
console.error("[Layout] 清理事件监听器失败", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [handleNotice]);
|
||||
}, [addListener, handleNotice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initRef.current) {
|
||||
@@ -314,6 +361,14 @@ const Layout = () => {
|
||||
let isInitialized = false;
|
||||
let initializationAttempts = 0;
|
||||
const maxAttempts = 3;
|
||||
const timers = new Set<number>();
|
||||
|
||||
const scheduleTimeout = (handler: () => void, delay: number) => {
|
||||
/* eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout -- timeout is registered in the timers set and cleared during cleanup */
|
||||
const id = window.setTimeout(handler, delay);
|
||||
timers.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const notifyBackend = async (action: string, stage?: string) => {
|
||||
try {
|
||||
@@ -334,7 +389,7 @@ const Layout = () => {
|
||||
if (initialOverlay) {
|
||||
console.log("[Layout] 移除加载指示器");
|
||||
initialOverlay.style.opacity = "0";
|
||||
setTimeout(() => {
|
||||
scheduleTimeout(() => {
|
||||
try {
|
||||
initialOverlay.remove();
|
||||
} catch {
|
||||
@@ -365,13 +420,13 @@ const Layout = () => {
|
||||
console.log("[Layout] React组件已挂载");
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkReactMount, 50);
|
||||
scheduleTimeout(checkReactMount, 50);
|
||||
}
|
||||
};
|
||||
|
||||
checkReactMount();
|
||||
|
||||
setTimeout(() => {
|
||||
scheduleTimeout(() => {
|
||||
console.log("[Layout] React组件挂载检查超时,继续执行");
|
||||
resolve();
|
||||
}, 2000);
|
||||
@@ -399,7 +454,7 @@ const Layout = () => {
|
||||
console.log(
|
||||
`[Layout] 将在500ms后进行第 ${initializationAttempts + 1} 次重试`,
|
||||
);
|
||||
setTimeout(performInitialization, 500);
|
||||
scheduleTimeout(performInitialization, 500);
|
||||
} else {
|
||||
console.error("[Layout] 所有初始化尝试都失败,执行紧急初始化");
|
||||
|
||||
@@ -408,7 +463,7 @@ const Layout = () => {
|
||||
await notifyBackend("UI就绪");
|
||||
isInitialized = true;
|
||||
} catch (e) {
|
||||
console.error("[Layout] 紧急初始化也失败:", e);
|
||||
console.error("[Layout] 紧急初始化也失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,11 +474,12 @@ const Layout = () => {
|
||||
const setupEventListener = async () => {
|
||||
try {
|
||||
console.log("[Layout] 开始监听启动完成事件");
|
||||
// TODO: 监听启动完成事件的实现
|
||||
} catch (err) {
|
||||
console.error("[Layout] 监听启动完成事件失败:", err);
|
||||
return () => {};
|
||||
}
|
||||
};
|
||||
void setupEventListener();
|
||||
|
||||
const checkImmediateInitialization = async () => {
|
||||
try {
|
||||
@@ -440,7 +496,7 @@ const Layout = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const backupInitialization = setTimeout(() => {
|
||||
const backupInitialization = scheduleTimeout(() => {
|
||||
if (!hasEventTriggered && !isInitialized) {
|
||||
console.warn("[Layout] 备用初始化触发:1.5秒内未开始初始化");
|
||||
hasEventTriggered = true;
|
||||
@@ -448,20 +504,29 @@ const Layout = () => {
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
const emergencyInitialization = setTimeout(() => {
|
||||
const emergencyInitialization = scheduleTimeout(() => {
|
||||
if (!isInitialized) {
|
||||
console.error("[Layout] 紧急初始化触发:5秒内未完成初始化");
|
||||
console.error("[Layout] 紧急初始化触发,5秒内未完成初始化");
|
||||
removeLoadingOverlay();
|
||||
notifyBackend("UI就绪").catch(() => {});
|
||||
isInitialized = true;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
setTimeout(checkImmediateInitialization, 100);
|
||||
const immediateInitTimer = scheduleTimeout(
|
||||
checkImmediateInitialization,
|
||||
100,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(backupInitialization);
|
||||
clearTimeout(emergencyInitialization);
|
||||
window.clearTimeout(backupInitialization);
|
||||
window.clearTimeout(emergencyInitialization);
|
||||
window.clearTimeout(immediateInitTimer);
|
||||
timers.delete(backupInitialization);
|
||||
timers.delete(emergencyInitialization);
|
||||
timers.delete(immediateInitTimer);
|
||||
timers.forEach((timeoutId) => window.clearTimeout(timeoutId));
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -474,10 +539,33 @@ const Layout = () => {
|
||||
}, [language, switchLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (start_page) {
|
||||
navigate(start_page, { replace: true });
|
||||
if (!start_page) {
|
||||
lastStartPageRef.current = null;
|
||||
startPageAppliedRef.current = false;
|
||||
return;
|
||||
}
|
||||
}, [start_page]);
|
||||
|
||||
const normalizedStartPage = start_page.startsWith("/")
|
||||
? start_page
|
||||
: `/${start_page}`;
|
||||
|
||||
if (lastStartPageRef.current !== normalizedStartPage) {
|
||||
lastStartPageRef.current = normalizedStartPage;
|
||||
startPageAppliedRef.current = false;
|
||||
}
|
||||
|
||||
if (startPageAppliedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
startPageAppliedRef.current = true;
|
||||
|
||||
if (location.pathname === normalizedStartPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(normalizedStartPage, { replace: true });
|
||||
}, [start_page, navigate, location.pathname]);
|
||||
|
||||
if (!themeReady) {
|
||||
return (
|
||||
@@ -622,9 +710,7 @@ const Layout = () => {
|
||||
|
||||
<div className="layout-content__right">
|
||||
<div className="the-bar"></div>
|
||||
<div className="the-content">
|
||||
{React.cloneElement(routersEles, { key: location.pathname })}
|
||||
</div>
|
||||
<div className="the-content">{routersEles}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
|
||||
@@ -36,8 +36,10 @@ type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
|
||||
const ConnectionsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const pageVisible = useVisibility();
|
||||
const [match, setMatch] = useState(() => (_: string) => true);
|
||||
const [curOrderOpt, setOrderOpt] = useState("Default");
|
||||
const [match, setMatch] = useState<(input: string) => boolean>(
|
||||
() => () => true,
|
||||
);
|
||||
const [curOrderOpt, setCurOrderOpt] = useState("Default");
|
||||
|
||||
const {
|
||||
response: { data: connections },
|
||||
@@ -195,7 +197,7 @@ const ConnectionsPage = () => {
|
||||
{!isTableLayout && (
|
||||
<BaseStyledSelect
|
||||
value={curOrderOpt}
|
||||
onChange={(e) => setOrderOpt(e.target.value)}
|
||||
onChange={(e) => setCurOrderOpt(e.target.value)}
|
||||
>
|
||||
{Object.keys(orderOpts).map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
|
||||
@@ -217,7 +217,7 @@ const ProfilePage = () => {
|
||||
// Batch selection states
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
// 防止重复切换
|
||||
@@ -899,6 +899,8 @@ const ProfilePage = () => {
|
||||
let lastUpdateTime = 0;
|
||||
const debounceDelay = 200;
|
||||
|
||||
let refreshTimer: number | null = null;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlistenPromise = listen<string>("profile-changed", (event) => {
|
||||
const newProfileId = event.payload;
|
||||
@@ -919,11 +921,16 @@ const ProfilePage = () => {
|
||||
|
||||
console.log(`[Profile] 执行配置数据刷新`);
|
||||
|
||||
if (refreshTimer !== null) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
}
|
||||
|
||||
// 使用异步调度避免阻塞事件处理
|
||||
setTimeout(() => {
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
mutateProfiles().catch((error) => {
|
||||
console.error("[Profile] 配置数据刷新失败:", error);
|
||||
});
|
||||
refreshTimer = null;
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
@@ -931,6 +938,9 @@ const ProfilePage = () => {
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (refreshTimer !== null) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
}
|
||||
unlistenPromise?.then((unlisten) => unlisten()).catch(console.error);
|
||||
};
|
||||
}, [mutateProfiles]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Button, ButtonGroup } from "@mui/material";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import { closeAllConnections, getBaseConfig } from "tauri-plugin-mihomo-api";
|
||||
@@ -28,7 +28,14 @@ const ProxyPage = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const [chainConfigData, setChainConfigData] = useState<string | null>(null);
|
||||
const [chainConfigData, dispatchChainConfigData] = useReducer(
|
||||
(_: string | null, action: string | null) => action,
|
||||
null as string | null,
|
||||
);
|
||||
|
||||
const updateChainConfigData = useCallback((value: string | null) => {
|
||||
dispatchChainConfigData(value);
|
||||
}, []);
|
||||
|
||||
const { data: clashConfig, mutate: mutateClash } = useSWR(
|
||||
"getClashConfig",
|
||||
@@ -78,30 +85,43 @@ const ProxyPage = () => {
|
||||
|
||||
// 当开启链式代理模式时,获取配置数据
|
||||
useEffect(() => {
|
||||
if (isChainMode) {
|
||||
const fetchChainConfig = async () => {
|
||||
try {
|
||||
const exitNode = localStorage.getItem("proxy-chain-exit-node");
|
||||
|
||||
if (!exitNode) {
|
||||
console.error("No proxy chain exit node found in localStorage");
|
||||
setChainConfigData("");
|
||||
return;
|
||||
}
|
||||
|
||||
const configData = await getRuntimeProxyChainConfig(exitNode);
|
||||
setChainConfigData(configData || "");
|
||||
} catch (error) {
|
||||
console.error("Failed to get runtime proxy chain config:", error);
|
||||
setChainConfigData("");
|
||||
}
|
||||
};
|
||||
|
||||
fetchChainConfig();
|
||||
} else {
|
||||
setChainConfigData(null);
|
||||
if (!isChainMode) {
|
||||
updateChainConfigData(null);
|
||||
return;
|
||||
}
|
||||
}, [isChainMode]);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const fetchChainConfig = async () => {
|
||||
try {
|
||||
const exitNode = localStorage.getItem("proxy-chain-exit-node");
|
||||
|
||||
if (!exitNode) {
|
||||
console.error("No proxy chain exit node found in localStorage");
|
||||
if (!cancelled) {
|
||||
updateChainConfigData("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const configData = await getRuntimeProxyChainConfig(exitNode);
|
||||
if (!cancelled) {
|
||||
updateChainConfigData(configData || "");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get runtime proxy chain config:", error);
|
||||
if (!cancelled) {
|
||||
updateChainConfigData("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchChainConfig();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isChainMode, updateChainConfigData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (curMode && !modeList.includes(curMode)) {
|
||||
|
||||
@@ -38,6 +38,51 @@ export const AppDataProvider = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { data: clashConfig, mutate: refreshClashConfig } = useSWR(
|
||||
"getClashConfig",
|
||||
getBaseConfig,
|
||||
{
|
||||
refreshInterval: 60000, // 60秒刷新间隔,减少频繁请求
|
||||
revalidateOnFocus: false,
|
||||
suspense: false,
|
||||
errorRetryCount: 3,
|
||||
},
|
||||
);
|
||||
|
||||
// 提供者数据
|
||||
const { data: proxyProviders, mutate: refreshProxyProviders } = useSWR(
|
||||
"getProxyProviders",
|
||||
calcuProxyProviders,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 3000,
|
||||
suspense: false,
|
||||
errorRetryCount: 3,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: ruleProviders, mutate: refreshRuleProviders } = useSWR(
|
||||
"getRuleProviders",
|
||||
getRuleProviders,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
suspense: false,
|
||||
errorRetryCount: 3,
|
||||
},
|
||||
);
|
||||
|
||||
// 低频率更新数据
|
||||
const { data: rulesData, mutate: refreshRules } = useSWR(
|
||||
"getRules",
|
||||
getRules,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
suspense: false,
|
||||
errorRetryCount: 3,
|
||||
},
|
||||
);
|
||||
|
||||
// 监听profile和clash配置变更事件
|
||||
useEffect(() => {
|
||||
let lastProfileId: string | null = null;
|
||||
@@ -47,7 +92,6 @@ export const AppDataProvider = ({
|
||||
let isUnmounted = false;
|
||||
const scheduledTimeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||
const cleanupFns: Array<() => void> = [];
|
||||
const fallbackWindowListeners: Array<[string, EventListener]> = [];
|
||||
|
||||
const registerCleanup = (fn: () => void) => {
|
||||
if (isUnmounted) {
|
||||
@@ -57,6 +101,12 @@ export const AppDataProvider = ({
|
||||
}
|
||||
};
|
||||
|
||||
const addWindowListener = (eventName: string, handler: EventListener) => {
|
||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener -- cleanup is returned by this helper
|
||||
window.addEventListener(eventName, handler);
|
||||
return () => window.removeEventListener(eventName, handler);
|
||||
};
|
||||
|
||||
const scheduleTimeout = (
|
||||
callback: () => void | Promise<void>,
|
||||
delay: number,
|
||||
@@ -70,40 +120,11 @@ export const AppDataProvider = ({
|
||||
return timeoutId;
|
||||
};
|
||||
|
||||
const clearScheduledTimeout = (
|
||||
timeoutId: ReturnType<typeof setTimeout>,
|
||||
) => {
|
||||
if (scheduledTimeouts.has(timeoutId)) {
|
||||
clearTimeout(timeoutId);
|
||||
scheduledTimeouts.delete(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllTimeouts = () => {
|
||||
scheduledTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
|
||||
scheduledTimeouts.clear();
|
||||
};
|
||||
|
||||
const withTimeout = async <T,>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
): Promise<T> => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = scheduleTimeout(() => reject(new Error(label)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId !== null) {
|
||||
clearScheduledTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileChanged = (event: { payload: string }) => {
|
||||
const newProfileId = event.payload;
|
||||
const now = Date.now();
|
||||
@@ -204,8 +225,7 @@ export const AppDataProvider = ({
|
||||
];
|
||||
|
||||
fallbackHandlers.forEach(([eventName, handler]) => {
|
||||
window.addEventListener(eventName, handler);
|
||||
fallbackWindowListeners.push([eventName, handler]);
|
||||
registerCleanup(addWindowListener(eventName, handler));
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -215,57 +235,9 @@ export const AppDataProvider = ({
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
clearAllTimeouts();
|
||||
fallbackWindowListeners.splice(0).forEach(([eventName, handler]) => {
|
||||
window.removeEventListener(eventName, handler);
|
||||
});
|
||||
cleanupFns.splice(0).forEach((fn) => fn());
|
||||
};
|
||||
}, [refreshProxy]);
|
||||
|
||||
const { data: clashConfig, mutate: refreshClashConfig } = useSWR(
|
||||
"getClashConfig",
|
||||
getBaseConfig,
|
||||
{
|
||||
refreshInterval: 60000, // 60秒刷新间隔,减少频繁请求
|
||||
revalidateOnFocus: false,
|
||||
suspense: false,
|
||||
errorRetryCount: 3,
|
||||
},
|
||||
);
|
||||
|
||||
// 提供者数据
|
||||
const { data: proxyProviders, mutate: refreshProxyProviders } = useSWR(
|
||||
"getProxyProviders",
|
||||
calcuProxyProviders,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 3000,
|
||||
suspense: false,
|
||||
errorRetryCount: 3,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: ruleProviders, mutate: refreshRuleProviders } = useSWR(
|
||||
"getRuleProviders",
|
||||
getRuleProviders,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
suspense: false,
|
||||
errorRetryCount: 3,
|
||||
},
|
||||
);
|
||||
|
||||
// 低频率更新数据
|
||||
const { data: rulesData, mutate: refreshRules } = useSWR(
|
||||
"getRules",
|
||||
getRules,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
suspense: false,
|
||||
errorRetryCount: 3,
|
||||
},
|
||||
);
|
||||
}, [refreshProxy, refreshRules, refreshRuleProviders]);
|
||||
|
||||
const { data: sysproxy, mutate: refreshSysproxy } = useSWR(
|
||||
"getSystemProxy",
|
||||
|
||||
20
src/providers/chain-proxy-context.ts
Normal file
20
src/providers/chain-proxy-context.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createContext, use } from "react";
|
||||
|
||||
export interface ChainProxyContextType {
|
||||
isChainMode: boolean;
|
||||
setChainMode: (isChain: boolean) => void;
|
||||
chainConfigData: string | null;
|
||||
setChainConfigData: (data: string | null) => void;
|
||||
}
|
||||
|
||||
export const ChainProxyContext = createContext<ChainProxyContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export const useChainProxy = () => {
|
||||
const context = use(ChainProxyContext);
|
||||
if (!context) {
|
||||
throw new Error("useChainProxy must be used within a ChainProxyProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,13 +1,6 @@
|
||||
import React, { createContext, useCallback, use, useState } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
|
||||
interface ChainProxyContextType {
|
||||
isChainMode: boolean;
|
||||
setChainMode: (isChain: boolean) => void;
|
||||
chainConfigData: string | null;
|
||||
setChainConfigData: (data: string | null) => void;
|
||||
}
|
||||
|
||||
const ChainProxyContext = createContext<ChainProxyContextType | null>(null);
|
||||
import { ChainProxyContext } from "./chain-proxy-context";
|
||||
|
||||
export const ChainProxyProvider = ({
|
||||
children,
|
||||
@@ -25,24 +18,15 @@ export const ChainProxyProvider = ({
|
||||
setChainConfigData(data);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChainProxyContext
|
||||
value={{
|
||||
isChainMode,
|
||||
setChainMode,
|
||||
chainConfigData,
|
||||
setChainConfigData: setChainConfigDataCallback,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChainProxyContext>
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
isChainMode,
|
||||
setChainMode,
|
||||
chainConfigData,
|
||||
setChainConfigData: setChainConfigDataCallback,
|
||||
}),
|
||||
[isChainMode, setChainMode, chainConfigData, setChainConfigDataCallback],
|
||||
);
|
||||
};
|
||||
|
||||
export const useChainProxy = () => {
|
||||
const context = use(ChainProxyContext);
|
||||
if (!context) {
|
||||
throw new Error("useChainProxy must be used within a ChainProxyProvider");
|
||||
}
|
||||
return context;
|
||||
return <ChainProxyContext value={contextValue}>{children}</ChainProxyContext>;
|
||||
};
|
||||
|
||||
18
src/providers/window/WindowContext.ts
Normal file
18
src/providers/window/WindowContext.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface WindowContextType {
|
||||
decorated: boolean | null;
|
||||
maximized: boolean | null;
|
||||
toggleDecorations: () => Promise<void>;
|
||||
refreshDecorated: () => Promise<boolean>;
|
||||
minimize: () => void;
|
||||
close: () => void;
|
||||
toggleMaximize: () => Promise<void>;
|
||||
toggleFullscreen: () => Promise<void>;
|
||||
currentWindow: ReturnType<typeof getCurrentWindow>;
|
||||
}
|
||||
|
||||
export const WindowContext = createContext<WindowContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
91
src/providers/window/WindowProvider.tsx
Normal file
91
src/providers/window/WindowProvider.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { WindowContext } from "./WindowContext";
|
||||
|
||||
export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const currentWindow = useMemo(() => getCurrentWindow(), []);
|
||||
const [decorated, setDecorated] = useState<boolean | null>(null);
|
||||
const [maximized, setMaximized] = useState<boolean | null>(null);
|
||||
|
||||
const close = useCallback(() => currentWindow.close(), [currentWindow]);
|
||||
const minimize = useCallback(() => currentWindow.minimize(), [currentWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const updateMaximized = async () => {
|
||||
const value = await currentWindow.isMaximized();
|
||||
if (!active) return;
|
||||
setMaximized((prev) => (prev === value ? prev : value));
|
||||
};
|
||||
|
||||
updateMaximized();
|
||||
const unlistenPromise = currentWindow.onResized(updateMaximized);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
unlistenPromise.then((unlisten) => unlisten());
|
||||
};
|
||||
}, [currentWindow]);
|
||||
|
||||
const toggleMaximize = useCallback(async () => {
|
||||
if (await currentWindow.isMaximized()) {
|
||||
await currentWindow.unmaximize();
|
||||
setMaximized(false);
|
||||
} else {
|
||||
await currentWindow.maximize();
|
||||
setMaximized(true);
|
||||
}
|
||||
}, [currentWindow]);
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
await currentWindow.setFullscreen(!(await currentWindow.isFullscreen()));
|
||||
}, [currentWindow]);
|
||||
|
||||
const refreshDecorated = useCallback(async () => {
|
||||
const val = await currentWindow.isDecorated();
|
||||
setDecorated((prev) => (prev === val ? prev : val));
|
||||
return val;
|
||||
}, [currentWindow]);
|
||||
|
||||
const toggleDecorations = useCallback(async () => {
|
||||
const currentVal = await currentWindow.isDecorated();
|
||||
await currentWindow.setDecorations(!currentVal);
|
||||
setDecorated(!currentVal);
|
||||
}, [currentWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshDecorated();
|
||||
currentWindow.setMinimizable?.(true);
|
||||
}, [currentWindow, refreshDecorated]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
decorated,
|
||||
maximized,
|
||||
toggleDecorations,
|
||||
refreshDecorated,
|
||||
minimize,
|
||||
close,
|
||||
toggleMaximize,
|
||||
toggleFullscreen,
|
||||
currentWindow,
|
||||
}),
|
||||
[
|
||||
decorated,
|
||||
maximized,
|
||||
toggleDecorations,
|
||||
refreshDecorated,
|
||||
minimize,
|
||||
close,
|
||||
toggleMaximize,
|
||||
toggleFullscreen,
|
||||
currentWindow,
|
||||
],
|
||||
);
|
||||
|
||||
return <WindowContext value={contextValue}>{children}</WindowContext>;
|
||||
};
|
||||
2
src/providers/window/index.ts
Normal file
2
src/providers/window/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./WindowContext";
|
||||
export * from "./WindowProvider";
|
||||
@@ -14,8 +14,7 @@ interface ILogItem {
|
||||
// Start logs monitoring with specified level
|
||||
export const startLogsStreaming = async (logLevel: LogLevel = "info") => {
|
||||
try {
|
||||
const level = logLevel === "all" ? undefined : logLevel;
|
||||
// await startLogsMonitoring(level);
|
||||
// await startLogsMonitoring(logLevel === "all" ? undefined : logLevel);
|
||||
console.log(
|
||||
`[IPC-LogService] Started logs monitoring with level: ${logLevel}`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user