refactor: frontend (#5068)

* refactor: setting components

* refactor: frontend

* fix: settings router
This commit is contained in:
Sline
2025-10-15 18:57:44 +08:00
committed by GitHub
parent a591ee1efc
commit 0b4403b67b
34 changed files with 1072 additions and 861 deletions

View File

@@ -37,7 +37,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
);
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准
return (
<Box

View File

@@ -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;

View File

@@ -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">

View File

@@ -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[]>([]);

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"));
}}
>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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();

View File

@@ -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")}

View File

@@ -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)}

View File

@@ -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

View File

@@ -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: "",

View File

@@ -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) {

View File

@@ -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(() => {

View File

@@ -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
View 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"
>;
};

View File

@@ -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 };
};

View File

@@ -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,

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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]);

View File

@@ -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)) {

View File

@@ -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",

View 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;
};

View File

@@ -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>;
};

View 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,
);

View 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>;
};

View File

@@ -0,0 +1,2 @@
export * from "./WindowContext";
export * from "./WindowProvider";

View File

@@ -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}`,
);