diff --git a/package.json b/package.json index 6b3be2ea7..fa06953a2 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "axios": "^1.13.3", "dayjs": "1.11.19", "foxact": "^0.2.52", + "foxts": "^5.2.1", "i18next": "^25.8.0", "ipaddr.js": "^2.3.0", "js-yaml": "^4.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79054fdf0..7c8345263 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: foxact: specifier: ^0.2.52 version: 0.2.52(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + foxts: + specifier: ^5.2.1 + version: 5.2.1 i18next: specifier: ^25.8.0 version: 25.8.0(typescript@5.9.3) @@ -2649,6 +2652,9 @@ packages: react-dom: optional: true + foxts@5.2.1: + resolution: {integrity: sha512-EsvV1QDTp8leo7RXluZbiIc1IjO9m06G6ePeX8P8VwJmKodSviS++2AKSXUh1GBMliFl57oH8ZV2fCJWEbh2rw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -6340,6 +6346,8 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + foxts@5.2.1: {} + fsevents@2.3.3: optional: true diff --git a/src/components/home/ip-info-card.tsx b/src/components/home/ip-info-card.tsx index eda84e268..f497da504 100644 --- a/src/components/home/ip-info-card.tsx +++ b/src/components/home/ip-info-card.tsx @@ -5,10 +5,12 @@ import { VisibilityOutlined, } from "@mui/icons-material"; import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { useEffect } from "foxact/use-abortable-effect"; +import { memo, useCallback, useState, useEffectEvent, useMemo } from "react"; import { useTranslation } from "react-i18next"; +import useSWR from "swr"; -import { useAppData } from "@/providers/app-data-context"; import { getIpInfo } from "@/services/api"; import { EnhancedCard } from "./enhanced-card"; @@ -17,8 +19,7 @@ import { EnhancedCard } from "./enhanced-card"; const IP_REFRESH_SECONDS = 300; const IP_INFO_CACHE_KEY = "cv_ip_info_cache"; -// 提取InfoItem子组件并使用memo优化 -const InfoItem = memo(({ label, value }: { label: string; value: string }) => ( +const InfoItem = memo(({ label, value }: { label: string; value?: string }) => ( ( )); // 获取国旗表情 -const getCountryFlag = (countryCode: string) => { +const getCountryFlag = (countryCode: string | undefined) => { if (!countryCode) return ""; const codePoints = countryCode .toUpperCase() @@ -56,149 +57,71 @@ const getCountryFlag = (countryCode: string) => { // IP信息卡片组件 export const IpInfoCard = () => { const { t } = useTranslation(); - const { clashConfig } = useAppData(); - const [ipInfo, setIpInfo] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); const [showIp, setShowIp] = useState(false); + const appWindow = useMemo(() => getCurrentWebviewWindow(), []); + const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS); - const lastFetchRef = useRef(null); - const fetchIpInfo = useCallback( - async (force = false) => { - setError(""); + const { + data: ipInfo, + error, + isLoading, + mutate, + } = useSWR(IP_INFO_CACHE_KEY, getIpInfo, { + refreshInterval: 0, + refreshWhenOffline: false, + revalidateOnFocus: true, + shouldRetryOnError: true, + }); - try { - if (!force && typeof window !== "undefined" && window.sessionStorage) { - const raw = window.sessionStorage.getItem(IP_INFO_CACHE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - const now = Date.now(); - if ( - parsed?.ts && - parsed?.data && - now - parsed.ts < IP_REFRESH_SECONDS * 1000 - ) { - setIpInfo(parsed.data); - lastFetchRef.current = parsed.ts; - const elapsed = Math.floor((now - parsed.ts) / 1000); - setCountdown(Math.max(IP_REFRESH_SECONDS - elapsed, 0)); - setLoading(false); - return; - } - } - } - } catch (e) { - console.warn("Failed to read IP info from sessionStorage:", e); - } + // function useEffectEvent + const onCountdownTick = useEffectEvent(async () => { + const now = Date.now(); + const ts = ipInfo?.lastFetchTs; + if (!ts) { + return; + } - if (typeof navigator !== "undefined" && !navigator.onLine) { - setLoading(false); - lastFetchRef.current = Date.now(); + const elapsed = Math.floor((now - ts) / 1000); + const remaining = IP_REFRESH_SECONDS - elapsed; + + if (remaining <= 0) { + if (navigator.onLine && (await appWindow.isVisible())) { + mutate(); setCountdown(IP_REFRESH_SECONDS); - return; + } else { + // do nothing. we even skip "setCountdown" to reduce re-renders + // + // but the remaining time still <= 0, and setInterval is not stopped, this + // callback will still be regularly triggered, as soon as the window is visible + // or network online again, we mutate() immediately in the following tick. } + } else { + setCountdown(remaining); + } + }); - if (!clashConfig) { - setLoading(false); - lastFetchRef.current = Date.now(); - setCountdown(IP_REFRESH_SECONDS); - return; - } - - try { - setLoading(true); - const data = await getIpInfo(); - setIpInfo(data); - const ts = Date.now(); - lastFetchRef.current = ts; - try { - if (typeof window !== "undefined" && window.sessionStorage) { - window.sessionStorage.setItem( - IP_INFO_CACHE_KEY, - JSON.stringify({ data, ts }), - ); - } - } catch (e) { - console.warn("Failed to write IP info to sessionStorage:", e); - } - setCountdown(IP_REFRESH_SECONDS); - } catch (err) { - setError( - err instanceof Error - ? err.message - : t("home.components.ipInfo.errors.load"), - ); - lastFetchRef.current = Date.now(); - setCountdown(IP_REFRESH_SECONDS); - } finally { - setLoading(false); - } - }, - [t, clashConfig], - ); - - // 组件加载时获取IP信息并启动基于上次请求时间的倒计时 + // Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired useEffect(() => { - fetchIpInfo(); - - let timer: number | null = null; - - const startCountdown = () => { - timer = window.setInterval(() => { - const now = Date.now(); - let ts = lastFetchRef.current; - try { - if (!ts && typeof window !== "undefined" && window.sessionStorage) { - const raw = window.sessionStorage.getItem(IP_INFO_CACHE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - ts = parsed?.ts || null; - } - } - } catch (e) { - console.warn("Failed to read IP info from sessionStorage:", e); - ts = ts || null; - } - - const elapsed = ts ? Math.floor((now - ts) / 1000) : 0; - let remaining = IP_REFRESH_SECONDS - elapsed; - - if (remaining <= 0) { - fetchIpInfo(); - remaining = IP_REFRESH_SECONDS; - } - - // 每5秒或倒计时结束时才更新UI - if (remaining % 5 === 0 || remaining <= 0) { - setCountdown(remaining); - } - }, 1000); - }; - - startCountdown(); + const timer: number | null = window.setInterval(onCountdownTick, 1000); return () => { - if (timer) clearInterval(timer); + if (timer != null) clearInterval(timer); }; - }, [fetchIpInfo]); + }, []); const toggleShowIp = useCallback(() => { setShowIp((prev) => !prev); }, []); - // 渲染加载状态 - if (loading) { + // Loading + if (isLoading) { return ( } iconColor="info" action={ - fetchIpInfo(true)} - disabled={true} - > + mutate()} disabled> } @@ -213,7 +136,7 @@ export const IpInfoCard = () => { ); } - // 渲染错误状态 + // Error if (error) { return ( { icon={} iconColor="info" action={ - fetchIpInfo(true)}> + mutate()}> } @@ -237,9 +160,11 @@ export const IpInfoCard = () => { }} > - {error} + {error instanceof Error + ? error.message + : t("home.components.ipInfo.errors.load")} - @@ -247,14 +172,14 @@ export const IpInfoCard = () => { ); } - // 渲染正常数据 + // Normal render return ( } iconColor="info" action={ - fetchIpInfo(true)}> + mutate()}> } @@ -355,7 +280,7 @@ export const IpInfoCard = () => { { whiteSpace: "nowrap", }} > - {ipInfo?.country_code}, {ipInfo?.longitude?.toFixed(2)},{" "} - {ipInfo?.latitude?.toFixed(2)} + {`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`} diff --git a/src/services/api.ts b/src/services/api.ts index 4c82e3d77..0b4cf25a8 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,4 +1,6 @@ import { fetch } from "@tauri-apps/plugin-http"; +import { asyncRetry } from "foxts/async-retry"; +import { extractErrorMessage } from "foxts/extract-error-message"; import { debugLog } from "@/utils/debug"; @@ -90,6 +92,38 @@ const IP_CHECK_SERVICES: ServiceConfig[] = [ timezone: data.timezone?.id || "", }), }, + { + url: "https://ip.api.skk.moe/cf-geoip", + mapping: (data) => ({ + ip: data.ip || "", + country_code: data.country || "", + country: data.country || "", + region: data.region || "", + city: data.city || "", + organization: data.asOrg || "", + asn: data.asn || 0, + asn_organization: data.asOrg || "", + longitude: data.longitude || 0, + latitude: data.latitude || 0, + timezone: data.timezone || "", + }), + }, + { + url: "https://get.geojs.io/v1/ip/geo.json", + mapping: (data) => ({ + ip: data.ip || "", + country_code: data.country_code || "", + country: data.country || "", + region: data.region || "", + city: data.city || "", + organization: data.organization_name || "", + asn: data.asn || 0, + asn_organization: data.organization_name || "", + longitude: Number(data.longitude) || 0, + latitude: Number(data.latitude) || 0, + timezone: data.timezone || "", + }), + }, ]; // 随机性服务列表洗牌函数 @@ -149,76 +183,74 @@ function createPrng(seed: number): () => number { } // 获取当前IP和地理位置信息 -export const getIpInfo = async (): Promise => { +export const getIpInfo = async (): Promise< + IpInfo & { lastFetchTs: number } +> => { + const lastFetchTs = Date.now(); + // 配置参数 const maxRetries = 3; const serviceTimeout = 5000; - const overallTimeout = 20000; // 增加总超时时间以容纳延迟 - const overallTimeoutController = new AbortController(); - const overallTimeoutId = setTimeout(() => { - overallTimeoutController.abort(); - }, overallTimeout); + const shuffledServices = shuffleServices(); + let lastError: unknown | null = null; - try { - const shuffledServices = shuffleServices(); - let lastError: Error | null = null; + for (const service of shuffledServices) { + debugLog(`尝试IP检测服务: ${service.url}`); - for (const service of shuffledServices) { - debugLog(`尝试IP检测服务: ${service.url}`); + const timeoutController = new AbortController(); + const timeoutId = setTimeout(() => { + timeoutController.abort(); + }, service.timeout || serviceTimeout); - for (let attempt = 0; attempt < maxRetries; attempt++) { - let timeoutId: ReturnType | null = null; - - try { - const timeoutController = new AbortController(); - timeoutId = setTimeout(() => { - timeoutController.abort(); - }, service.timeout || serviceTimeout); + try { + return await asyncRetry( + async (bail) => { console.debug("Fetching IP information..."); const response = await fetch(service.url, { method: "GET", - signal: timeoutController.signal, + signal: timeoutController.signal, // AbortSignal.timeout(service.timeout || serviceTimeout), connectTimeout: service.timeout || serviceTimeout, }); - const data = await response.json(); + if (!response.ok) { + return bail( + new Error( + `IP 检测服务出错,状态码: ${response.status} from ${service.url}`, + ), + ); + } - if (timeoutId) clearTimeout(timeoutId); + const data = await response.json(); if (data && data.ip) { debugLog(`IP检测成功,使用服务: ${service.url}`); - return service.mapping(data); + return Object.assign(service.mapping(data), { lastFetchTs }); } else { throw new Error(`无效的响应格式 from ${service.url}`); } - } catch (error: any) { - if (timeoutId) clearTimeout(timeoutId); - - lastError = error; - console.warn( - `尝试 ${attempt + 1}/${maxRetries} 失败 (${service.url}):`, - error, - ); - - if (error.name === "AbortError") { - throw error; - } - - if (attempt < maxRetries - 1) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - } + }, + { + retries: maxRetries, + minTimeout: 500, + maxTimeout: 2000, + randomize: true, + }, + ); + } catch (error) { + debugLog(`IP检测服务失败: ${service.url}`, error); + lastError = error; + } finally { + clearTimeout(timeoutId); } + } - if (lastError) { - throw new Error(`所有IP检测服务都失败: ${lastError.message}`); - } else { - throw new Error("没有可用的IP检测服务"); - } - } finally { - clearTimeout(overallTimeoutId); + if (lastError) { + throw new Error( + `所有IP检测服务都失败: ${extractErrorMessage(lastError) || "未知错误"}`, + ); + } else { + throw new Error("没有可用的IP检测服务"); } };