diff --git a/src/main/utils/template.ts b/src/main/utils/template.ts index f8c3a164..37d23835 100644 --- a/src/main/utils/template.ts +++ b/src/main/utils/template.ts @@ -24,6 +24,7 @@ export const defaultConfig: IAppConfig = { autoCheckUpdate: true, autoCloseConnection: true, subscriptionTimeout: 30000, + networkLatencyTargets: [], useNameserverPolicy: false, controlDns: true, controlSniff: true, diff --git a/src/renderer/src/locales/en-US.json b/src/renderer/src/locales/en-US.json index 538484c5..8f610ebd 100644 --- a/src/renderer/src/locales/en-US.json +++ b/src/renderer/src/locales/en-US.json @@ -345,6 +345,11 @@ "network.latency.title": "Network Latency", "network.latency.average": "Avg", "network.latency.timeout": "Timeout", + "network.latency.namePlaceholder": "Name", + "network.latency.urlPlaceholder": "Domain or URL", + "network.latency.add": "Add address", + "network.latency.invalidName": "Enter a name", + "network.latency.invalidUrl": "Enter a valid HTTP/HTTPS address", "sider.cards.config": "Runtime Config", "sider.cards.emptyProfile": "Empty Profile", "sider.cards.viewRuntimeConfig": "View Runtime Config", diff --git a/src/renderer/src/locales/fa-IR.json b/src/renderer/src/locales/fa-IR.json index 7268fd12..c8859f5b 100644 --- a/src/renderer/src/locales/fa-IR.json +++ b/src/renderer/src/locales/fa-IR.json @@ -320,6 +320,11 @@ "network.latency.title": "تاخیر شبکه", "network.latency.average": "میانگین", "network.latency.timeout": "وقفه", + "network.latency.namePlaceholder": "نام", + "network.latency.urlPlaceholder": "دامنه یا URL", + "network.latency.add": "افزودن آدرس", + "network.latency.invalidName": "نام را وارد کنید", + "network.latency.invalidUrl": "یک آدرس HTTP/HTTPS معتبر وارد کنید", "sider.cards.config": "پیکربندی اجرا", "sider.cards.emptyProfile": "پروفایل خالی", "sider.cards.viewRuntimeConfig": "مشاهده پیکربندی اجرا", diff --git a/src/renderer/src/locales/ru-RU.json b/src/renderer/src/locales/ru-RU.json index 30c4eecd..8bf39e83 100644 --- a/src/renderer/src/locales/ru-RU.json +++ b/src/renderer/src/locales/ru-RU.json @@ -322,6 +322,11 @@ "network.latency.title": "Задержка сети", "network.latency.average": "Среднее", "network.latency.timeout": "Таймаут", + "network.latency.namePlaceholder": "Название", + "network.latency.urlPlaceholder": "Домен или URL", + "network.latency.add": "Добавить адрес", + "network.latency.invalidName": "Введите название", + "network.latency.invalidUrl": "Введите корректный HTTP/HTTPS адрес", "sider.cards.config": "Конфигурация", "sider.cards.emptyProfile": "Пустой профиль", "sider.cards.viewRuntimeConfig": "Просмотр текущей конфигурации", diff --git a/src/renderer/src/locales/zh-CN.json b/src/renderer/src/locales/zh-CN.json index df0cf8ba..ce3448b9 100644 --- a/src/renderer/src/locales/zh-CN.json +++ b/src/renderer/src/locales/zh-CN.json @@ -345,6 +345,11 @@ "network.latency.title": "网络延迟", "network.latency.average": "平均", "network.latency.timeout": "超时", + "network.latency.namePlaceholder": "名称", + "network.latency.urlPlaceholder": "域名或 URL", + "network.latency.add": "添加地址", + "network.latency.invalidName": "请输入名称", + "network.latency.invalidUrl": "请输入有效的 HTTP/HTTPS 地址", "sider.cards.config": "运行时配置", "sider.cards.emptyProfile": "空白配置", "sider.cards.viewRuntimeConfig": "查看运行时配置", diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index 87bccd5b..ce963212 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -345,6 +345,11 @@ "network.latency.title": "網路延遲", "network.latency.average": "平均", "network.latency.timeout": "逾時", + "network.latency.namePlaceholder": "名稱", + "network.latency.urlPlaceholder": "網域或 URL", + "network.latency.add": "新增地址", + "network.latency.invalidName": "請輸入名稱", + "network.latency.invalidUrl": "請輸入有效的 HTTP/HTTPS 地址", "sider.cards.config": "運行時配置", "sider.cards.emptyProfile": "空白配置", "sider.cards.viewRuntimeConfig": "查看運行時配置", diff --git a/src/renderer/src/pages/network.tsx b/src/renderer/src/pages/network.tsx index b81352c0..3bb44d59 100644 --- a/src/renderer/src/pages/network.tsx +++ b/src/renderer/src/pages/network.tsx @@ -1,17 +1,20 @@ import BasePage from '@renderer/components/base/base-page' import NetworkTopologyCard from '@renderer/components/network/network-topology' -import React, { useState, useEffect, useCallback } from 'react' -import { Button, Select, SelectItem, Chip, Tooltip } from '@heroui/react' +import React, { useState, useEffect, useCallback, useMemo } from 'react' +import { Button, Select, SelectItem, Chip, Tooltip, Input } from '@heroui/react' import { IoRefresh, IoCopyOutline, IoCheckmark, IoEyeOutline, - IoEyeOffOutline + IoEyeOffOutline, + IoAdd, + IoClose } from 'react-icons/io5' import { IoMdGlobe, IoMdPulse } from 'react-icons/io' import { useTranslation } from 'react-i18next' import { fetchIPInfo, measureLatency } from '@renderer/utils/ipc' +import { useAppConfig } from '@renderer/hooks/use-app-config' type IPProvider = 'ip.sb' | 'ipwho.is' | 'ipapi.is' @@ -104,12 +107,68 @@ interface LatencyResult { status: LatencyStatus } -const LATENCY_TARGETS = [ +interface LatencyTarget { + name: string + url: string + custom?: boolean +} + +const DEFAULT_LATENCY_TARGETS: LatencyTarget[] = [ { name: 'Google', url: 'https://www.google.com/generate_204' }, { name: 'Cloudflare', url: 'https://www.cloudflare.com/cdn-cgi/trace' }, - { name: 'GitHub', url: 'https://github.com' } + { name: 'GitHub', url: 'https://github.com/' } ] +function normalizeLatencyUrl(value: string): string | null { + const trimmed = value.trim() + if (!trimmed) return null + + const urlText = /^[a-z][a-z\d+\-.]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` + try { + const url = new URL(urlText) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + return url.toString() + } catch { + return null + } +} + +function normalizeLatencyTarget( + value: string | Partial +): INetworkLatencyTarget | null { + if (typeof value === 'string') { + const url = normalizeLatencyUrl(value) + if (!url) return null + return { name: getLatencyTargetName(url), url } + } + + const url = normalizeLatencyUrl(value.url ?? '') + if (!url) return null + return { name: value.name?.trim() || getLatencyTargetName(url), url } +} + +function normalizeLatencyTargets( + values: Array> = [] +): INetworkLatencyTarget[] { + const result: INetworkLatencyTarget[] = [] + const seen = new Set() + for (const value of values) { + const target = normalizeLatencyTarget(value) + if (!target || seen.has(target.url)) continue + seen.add(target.url) + result.push(target) + } + return result +} + +function getLatencyTargetName(url: string): string { + try { + return new URL(url).hostname || url + } catch { + return url + } +} + function latencyColor(latency: number | null): string { if (latency === null) return '' if (latency < 100) return 'text-success' @@ -151,27 +210,129 @@ const InfoRow: React.FC = ({ label, value, mono }) => ( const IPPage: React.FC = () => { const { t } = useTranslation() + const { appConfig, patchAppConfig } = useAppConfig() const [provider, setProvider] = useState('ip.sb') const [ipInfo, setIpInfo] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [copied, setCopied] = useState(false) const [hidden, setHidden] = useState(false) + const [isAddingLatencyTarget, setIsAddingLatencyTarget] = useState(false) + const [customLatencyName, setCustomLatencyName] = useState('') + const [customLatencyUrl, setCustomLatencyUrl] = useState('') + const [customLatencyNameError, setCustomLatencyNameError] = useState(false) + const [customLatencyUrlError, setCustomLatencyUrlError] = useState(false) // latency state: map from url -> result const [latencyResults, setLatencyResults] = useState>({}) const [testingLatency, setTestingLatency] = useState(false) + const appConfigLoaded = appConfig !== undefined + + const customLatencyTargets = useMemo( + () => normalizeLatencyTargets(appConfig?.networkLatencyTargets), + [appConfig?.networkLatencyTargets] + ) + + const latencyTargets = useMemo(() => { + const defaultUrls = new Set(DEFAULT_LATENCY_TARGETS.map((target) => target.url)) + return [ + ...DEFAULT_LATENCY_TARGETS, + ...customLatencyTargets + .filter((target) => !defaultUrls.has(target.url)) + .map((target) => ({ ...target, custom: true })) + ] + }, [customLatencyTargets]) + + const patchCustomLatencyTargets = useCallback( + async (targets: Array>) => { + const defaultUrls = new Set(DEFAULT_LATENCY_TARGETS.map((target) => target.url)) + await patchAppConfig({ + networkLatencyTargets: normalizeLatencyTargets(targets).filter( + (target) => !defaultUrls.has(target.url) + ) + }) + }, + [patchAppConfig] + ) + + const closeLatencyTargetForm = useCallback(() => { + setIsAddingLatencyTarget(false) + setCustomLatencyName('') + setCustomLatencyUrl('') + setCustomLatencyNameError(false) + setCustomLatencyUrlError(false) + }, []) + + const addCustomLatencyTarget = useCallback(async () => { + const name = customLatencyName.trim() + const url = normalizeLatencyUrl(customLatencyUrl) + setCustomLatencyNameError(!name) + setCustomLatencyUrlError(!url) + if (!name || !url) return + + const existingUrls = new Set(DEFAULT_LATENCY_TARGETS.map((target) => target.url)) + if (!existingUrls.has(url)) { + await patchCustomLatencyTargets([ + ...customLatencyTargets.filter((target) => target.url !== url), + { name, url } + ]) + } + closeLatencyTargetForm() + }, [ + closeLatencyTargetForm, + customLatencyName, + customLatencyTargets, + customLatencyUrl, + patchCustomLatencyTargets + ]) + + const openLatencyTargetForm = useCallback(() => { + setIsAddingLatencyTarget(true) + }, []) + + const updateCustomLatencyName = useCallback((value: string) => { + setCustomLatencyName(value) + setCustomLatencyNameError(false) + }, []) + + const updateCustomLatencyUrl = useCallback((value: string) => { + setCustomLatencyUrl(value) + setCustomLatencyUrlError(false) + }, []) + + const submitLatencyTargetOnEnter = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== 'Enter') return + event.preventDefault() + addCustomLatencyTarget() + }, + [addCustomLatencyTarget] + ) + + const removeCustomLatencyTarget = useCallback( + async (url: string) => { + await patchCustomLatencyTargets( + customLatencyTargets.filter((targetUrl) => targetUrl.url !== url) + ) + setLatencyResults((prev) => { + const next = { ...prev } + delete next[url] + return next + }) + }, + [customLatencyTargets, patchCustomLatencyTargets] + ) const testAllLatencies = useCallback(async () => { setTestingLatency(true) // mark all as pending first setLatencyResults( Object.fromEntries( - LATENCY_TARGETS.map((t) => [t.url, { latency: null, status: 'pending' as LatencyStatus }]) + latencyTargets.map((t) => [t.url, { latency: null, status: 'pending' as LatencyStatus }]) ) ) await Promise.all( - LATENCY_TARGETS.map(async (target) => { + latencyTargets.map(async (target) => { try { const latency = await measureLatency(target.url) setLatencyResults((prev) => ({ @@ -187,17 +348,20 @@ const IPPage: React.FC = () => { }) ) setTestingLatency(false) - }, []) + }, [latencyTargets]) useEffect(() => { + if (!appConfigLoaded) return testAllLatencies() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [appConfigLoaded, testAllLatencies]) const averageLatency = (() => { - const successes = LATENCY_TARGETS.map((t) => latencyResults[t.url]).filter( - (r): r is LatencyResult & { latency: number } => r?.status === 'success' && r.latency !== null - ) + const successes = latencyTargets + .map((t) => latencyResults[t.url]) + .filter( + (r): r is LatencyResult & { latency: number } => + r?.status === 'success' && r.latency !== null + ) if (successes.length === 0) return null return Math.round(successes.reduce((acc, r) => acc + r.latency, 0) / successes.length) })() @@ -414,6 +578,18 @@ const IPPage: React.FC = () => { {t('network.latency.average')}: {averageLatency}ms )} + + + + + + + + + )} +
- {LATENCY_TARGETS.map((target) => { + {latencyTargets.map((target) => { const res = latencyResults[target.url] return (
@@ -458,6 +686,23 @@ const IPPage: React.FC = () => { {t('network.latency.timeout')} )} + {target.custom ? ( + + + + ) : ( + + )}
) })} diff --git a/src/shared/types.d.ts b/src/shared/types.d.ts index 447df571..61920099 100644 --- a/src/shared/types.d.ts +++ b/src/shared/types.d.ts @@ -236,6 +236,11 @@ interface ISysProxyConfig { pacScript?: string } +interface INetworkLatencyTarget { + name: string + url: string +} + interface IAppConfig { core: 'mihomo' | 'mihomo-alpha' | 'mihomo-smart' | 'mihomo-specific' specificVersion?: string @@ -318,6 +323,7 @@ interface IAppConfig { delayTestConcurrency?: number delayTestUrl?: string delayTestTimeout?: number + networkLatencyTargets?: INetworkLatencyTarget[] subscriptionTimeout?: number encryptedPassword?: number[] controlDns?: boolean