diff --git a/src-tauri/src/core/handle.rs b/src-tauri/src/core/handle.rs index ef868f59..036aaec6 100644 --- a/src-tauri/src/core/handle.rs +++ b/src-tauri/src/core/handle.rs @@ -1,7 +1,14 @@ -use crate::{APP_HANDLE, constants::timing, singleton}; +use crate::{ + APP_HANDLE, config::Config, constants::timing, logging, singleton, utils::logging::Type, +}; use parking_lot::RwLock; +use serde_json::{Value, json}; use smartstring::alias::String; -use std::{sync::Arc, thread}; +use std::{ + sync::Arc, + thread, + time::{SystemTime, UNIX_EPOCH}, +}; use tauri::{AppHandle, Manager, WebviewWindow}; use tauri_plugin_mihomo::{Mihomo, MihomoExt}; use tokio::sync::RwLockReadGuard; @@ -69,7 +76,10 @@ impl Handle { let system_opt = handle.notification_system.read(); if let Some(system) = system_opt.as_ref() { system.send_event(FrontendEvent::RefreshClash); + system.send_event(FrontendEvent::RefreshProxy); } + + Self::spawn_proxy_snapshot(); } pub fn refresh_verge() { @@ -100,6 +110,86 @@ impl Handle { pub fn notify_profile_update_completed(uid: String) { Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid }); + Self::spawn_proxy_snapshot(); + } + + pub fn notify_proxies_updated(payload: Value) { + Self::send_event(FrontendEvent::ProxiesUpdated { payload }); + } + + pub async fn build_proxy_snapshot() -> Option { + let mihomo_guard = Self::mihomo().await; + let proxies = match mihomo_guard.get_proxies().await { + Ok(data) => match serde_json::to_value(&data) { + Ok(value) => value, + Err(error) => { + logging!( + warn, + Type::Frontend, + "Failed to serialize proxies snapshot: {error}" + ); + return None; + } + }, + Err(error) => { + logging!( + warn, + Type::Frontend, + "Failed to fetch proxies for snapshot: {error}" + ); + return None; + } + }; + + drop(mihomo_guard); + + let providers_guard = Self::mihomo().await; + let providers_value = match providers_guard.get_proxy_providers().await { + Ok(data) => serde_json::to_value(&data).unwrap_or_else(|error| { + logging!( + warn, + Type::Frontend, + "Failed to serialize proxy providers for snapshot: {error}" + ); + Value::Null + }), + Err(error) => { + logging!( + warn, + Type::Frontend, + "Failed to fetch proxy providers for snapshot: {error}" + ); + Value::Null + } + }; + + drop(providers_guard); + + let profile_guard = Config::profiles().await; + let profile_id = profile_guard.latest_ref().current.clone(); + drop(profile_guard); + + let emitted_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0); + + let payload = json!({ + "proxies": proxies, + "providers": providers_value, + "profileId": profile_id, + "emittedAt": emitted_at, + }); + + Some(payload) + } + + fn spawn_proxy_snapshot() { + tauri::async_runtime::spawn(async { + if let Some(payload) = Handle::build_proxy_snapshot().await { + Handle::notify_proxies_updated(payload); + } + }); } pub fn notice_message, M: Into>(status: S, msg: M) { diff --git a/src-tauri/src/core/notification.rs b/src-tauri/src/core/notification.rs index 08102fc0..465eee03 100644 --- a/src-tauri/src/core/notification.rs +++ b/src-tauri/src/core/notification.rs @@ -19,6 +19,8 @@ use tauri::{Emitter, WebviewWindow}; pub enum FrontendEvent { RefreshClash, RefreshVerge, + RefreshProxy, + ProxiesUpdated { payload: serde_json::Value }, NoticeMessage { status: String, message: String }, ProfileChanged { current_profile_id: String }, TimerUpdated { profile_index: String }, @@ -99,7 +101,7 @@ impl NotificationSystem { match rx.recv_timeout(std::time::Duration::from_millis(100)) { Ok(event) => Self::process_event(handle, event), Err(mpsc::RecvTimeoutError::Disconnected) => break, - Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Timeout) => {} } } } @@ -160,6 +162,8 @@ impl NotificationSystem { "verge://notice-message", serde_json::to_value((status, message)), ), + FrontendEvent::RefreshProxy => ("verge://refresh-proxy-config", Ok(json!("yes"))), + FrontendEvent::ProxiesUpdated { payload } => ("proxies-updated", Ok(payload)), FrontendEvent::ProfileChanged { current_profile_id } => { ("profile-changed", Ok(json!(current_profile_id))) } diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index ceea82d7..ed74def8 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -100,10 +100,12 @@ export const CurrentProxyCard = () => { const { t } = useTranslation(); const navigate = useNavigate(); const theme = useTheme(); - const { proxies, clashConfig, refreshProxy, rules } = useAppData(); + const { proxies, proxyHydration, clashConfig, refreshProxy, rules } = + useAppData(); const { verge } = useVerge(); const { current: currentProfile } = useProfiles(); const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false; + const isLiveHydration = proxyHydration === "live"; const currentProfileId = currentProfile?.uid || null; const getProfileStorageKey = useCallback( @@ -715,7 +717,6 @@ export const CurrentProxyCard = () => { ); } } - refreshProxy(); if (sortType === 1) { setDelaySortRefresh((prev) => prev + 1); @@ -840,13 +841,24 @@ export const CurrentProxyCard = () => { iconColor={currentProxy ? "primary" : undefined} action={ + {!isLiveHydration && ( + + )} @@ -960,7 +972,7 @@ export const CurrentProxyCard = () => { value={state.selection.group} onChange={handleGroupChange} label={t("Group")} - disabled={isGlobalMode || isDirectMode} + disabled={isGlobalMode || isDirectMode || !isLiveHydration} > {state.proxyData.groups.map((group) => ( @@ -978,7 +990,7 @@ export const CurrentProxyCard = () => { value={state.selection.proxy} onChange={handleProxyChange} label={t("Proxy")} - disabled={isDirectMode} + disabled={isDirectMode || !isLiveHydration} renderValue={renderProxyValue} MenuProps={{ PaperProps: { diff --git a/src/components/proxy/provider-button.tsx b/src/components/proxy/provider-button.tsx index e22b856e..4d0f2c39 100644 --- a/src/components/proxy/provider-button.tsx +++ b/src/components/proxy/provider-button.tsx @@ -1,6 +1,7 @@ import { RefreshRounded, StorageOutlined } from "@mui/icons-material"; import { Box, + Chip, Button, Dialog, DialogActions, @@ -18,7 +19,7 @@ import { } from "@mui/material"; import { useLockFn } from "ahooks"; import dayjs from "dayjs"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { updateProxyProvider } from "tauri-plugin-mihomo-api"; @@ -48,29 +49,61 @@ const parseExpire = (expire?: number) => { export const ProviderButton = () => { const { t } = useTranslation(); const [open, setOpen] = useState(false); - const { proxyProviders, refreshProxy, refreshProxyProviders } = useAppData(); + const { + proxyProviders, + proxyHydration, + refreshProxy, + refreshProxyProviders, + } = useAppData(); + + const isHydrating = proxyHydration !== "live"; const [updating, setUpdating] = useState>({}); // 检查是否有提供者 const hasProviders = Object.keys(proxyProviders || {}).length > 0; + // Hydration hint badge keeps users aware of sync state + const hydrationChip = useMemo(() => { + if (proxyHydration === "live") return null; + + return ( + + ); + }, [proxyHydration, t]); + // 更新单个代理提供者 const updateProvider = useLockFn(async (name: string) => { + if (isHydrating) { + showNotice("info", t("Proxy data is syncing, please wait")); + return; + } + try { // 设置更新状态 setUpdating((prev) => ({ ...prev, [name]: true })); - await updateProxyProvider(name); - - // 刷新数据 - await refreshProxy(); await refreshProxyProviders(); - - showNotice("success", `${name} 更新成功`); + await refreshProxy(); + showNotice( + "success", + t("Provider {{name}} updated successfully", { name }), + ); } catch (err: any) { showNotice( "error", - `${name} 更新失败: ${err?.message || err.toString()}`, + t("Provider {{name}} update failed: {{message}}", { + name, + message: err?.message || err.toString(), + }), ); } finally { // 清除更新状态 @@ -80,11 +113,16 @@ export const ProviderButton = () => { // 更新所有代理提供者 const updateAllProviders = useLockFn(async () => { + if (isHydrating) { + showNotice("info", t("Proxy data is syncing, please wait")); + return; + } + try { // 获取所有provider的名称 const allProviders = Object.keys(proxyProviders || {}); if (allProviders.length === 0) { - showNotice("info", "没有可更新的代理提供者"); + showNotice("info", t("No providers to update")); return; } @@ -110,54 +148,67 @@ export const ProviderButton = () => { } } - // 刷新数据 - await refreshProxy(); await refreshProxyProviders(); - - showNotice("success", "全部代理提供者更新成功"); + await refreshProxy(); + showNotice("success", t("All providers updated successfully")); } catch (err: any) { - showNotice("error", `更新失败: ${err?.message || err.toString()}`); + showNotice( + "error", + t("Failed to update providers: {{message}}", { + message: err?.message || err.toString(), + }), + ); } finally { // 清除所有更新状态 setUpdating({}); } }); - const handleClose = () => { - setOpen(false); - }; + const handleClose = () => setOpen(false); if (!hasProviders) return null; return ( <> - + + + {hydrationChip} + {t("Proxy Provider")} - - - + @@ -166,54 +217,63 @@ export const ProviderButton = () => { {Object.entries(proxyProviders || {}) .sort() .map(([key, item]) => { - const provider = item; - const time = dayjs(provider.updatedAt); + if (!item) return null; + + const time = dayjs(item.updatedAt); const isUpdating = updating[key]; - - // 订阅信息 - const sub = provider.subscriptionInfo; - const hasSubInfo = !!sub; - const upload = sub?.Upload || 0; - const download = sub?.Download || 0; - const total = sub?.Total || 0; - const expire = sub?.Expire || 0; - - // 流量使用进度 + const sub = item.subscriptionInfo; + const hasSubInfo = Boolean(sub); + const upload = sub?.Upload ?? 0; + const download = sub?.Download ?? 0; + const total = sub?.Total ?? 0; + const expire = sub?.Expire ?? 0; const progress = total > 0 ? Math.min( - Math.round(((download + upload) * 100) / total) + 1, 100, + Math.max(0, ((upload + download) / total) * 100), ) : 0; return ( { - const bgcolor = - mode === "light" ? "#ffffff" : "#24252f"; - const hoverColor = - mode === "light" - ? alpha(primary.main, 0.1) - : alpha(primary.main, 0.2); - - return { - backgroundColor: bgcolor, - "&:hover": { - backgroundColor: hoverColor, - }, - }; - }, - ]} + secondaryAction={ + + updateProvider(key)} + disabled={isUpdating || isHydrating} + sx={{ + animation: isUpdating + ? "spin 1s linear infinite" + : "none", + "@keyframes spin": { + "0%": { transform: "rotate(0deg)" }, + "100%": { transform: "rotate(360deg)" }, + }, + }} + title={t("Update Provider") as string} + > + + + + } + sx={{ + mb: 1, + borderRadius: 1, + border: "1px solid", + borderColor: alpha("#ccc", 0.4), + backgroundColor: alpha("#fff", 0.02), + }} > { display: "flex", justifyContent: "space-between", alignItems: "center", + gap: 1, }} > { title={key} sx={{ display: "flex", alignItems: "center" }} > - {key} + {key} - {provider.proxies.length} + {item.proxies.length} - {provider.vehicleType} + {item.vehicleType} @@ -252,72 +313,39 @@ export const ProviderButton = () => { } secondary={ - <> - {/* 订阅信息 */} - {hasSubInfo && ( - <> - - - {parseTraffic(upload + download)} /{" "} - {parseTraffic(total)} - - - {parseExpire(expire)} - - + hasSubInfo ? ( + <> + + + {parseTraffic(upload + download)} /{" "} + {parseTraffic(total)} + + + {parseExpire(expire)} + + - {/* 进度条 */} - 0 ? 1 : 0, - }} - /> - - )} - + 0 ? 1 : 0, + }} + /> + + ) : null } /> - - { - updateProvider(key); - }} - disabled={isUpdating} - sx={{ - animation: isUpdating - ? "spin 1s linear infinite" - : "none", - "@keyframes spin": { - "0%": { transform: "rotate(0deg)" }, - "100%": { transform: "rotate(360deg)" }, - }, - }} - title={t("Update Provider") as string} - > - - - ); })} diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index eec3f2e8..3ab01175 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -61,7 +61,7 @@ export const ProxyGroups = (props: Props) => { }>({ open: false, message: "" }); const { verge } = useVerge(); - const { proxies: proxiesData } = useAppData(); + const { proxies: proxiesData, proxyHydration } = useAppData(); const groups = proxiesData?.groups; const availableGroups = useMemo(() => groups ?? [], [groups]); @@ -76,6 +76,21 @@ export const ProxyGroups = (props: Props) => { () => selectedGroup ?? defaultRuleGroup, [selectedGroup, defaultRuleGroup], ); + const hydrationChip = useMemo(() => { + if (proxyHydration === "live") return null; + + const label = + proxyHydration === "snapshot" ? t("Snapshot data") : t("Syncing..."); + + return ( + + ); + }, [proxyHydration, t]); const { renderList, onProxies, onHeadState } = useRenderList( mode, @@ -93,7 +108,7 @@ export const ProxyGroups = (props: Props) => { [renderList], ); - // 统代理选择 + // 系统代理选择 const { handleProxyGroupChange } = useProxySelection({ onSuccess: () => { onProxies(); @@ -306,12 +321,7 @@ export const ProxyGroups = (props: Props) => { try { await Promise.race([ delayManager.checkListDelay(names, groupName, timeout), - delayGroup(groupName, url, timeout).then((result) => { - console.log( - `[ProxyGroups] getGroupProxyDelays返回结果数量:`, - Object.keys(result || {}).length, - ); - }), // 查询group delays 将清除fixed(不关注调用结果) + delayGroup(groupName, url, timeout), ]); console.log(`[ProxyGroups] 延迟测试完成,组: ${groupName}`); } catch (error) { @@ -455,8 +465,8 @@ export const ProxyGroups = (props: Props) => { ref={virtuosoRef} style={{ height: - mode === "rule" && proxyGroups.length > 0 - ? "calc(100% - 80px)" // 只有标题的高度 + mode === "rule" && proxyGroupNames.length > 0 + ? "calc(100% - 80px)" : "calc(100% - 14px)", }} totalCount={renderList.length} @@ -547,17 +557,16 @@ export const ProxyGroups = (props: Props) => { {group.name} - - {group.type} · {group.all.length} 节点 - + ))} {availableGroups.length === 0 && ( - - 暂无可用代理组 - + )} @@ -569,7 +578,21 @@ export const ProxyGroups = (props: Props) => {
- {/* 代理组导航栏 */} + {hydrationChip && ( + + {hydrationChip} + + )} {mode === "rule" && ( { // 使用全局数据提供者 - const { proxies: proxiesData, refreshProxy } = useAppData(); + const { proxies: proxiesData, proxyHydration, refreshProxy } = useAppData(); const { verge } = useVerge(); const { width } = useWindowWidth(); const [headStates, setHeadState] = useHeadStateNew(); @@ -123,17 +86,29 @@ export const useRenderList = ( // 确保代理数据加载 useEffect(() => { - if (!proxiesData) return; + if (!proxiesData || proxyHydration !== "live") return; const { groups, proxies } = proxiesData; if ( (mode === "rule" && !groups.length) || (mode === "global" && proxies.length < 2) ) { - const handle = setTimeout(() => refreshProxy(), 500); + const handle = setTimeout(() => { + void refreshProxy().catch(() => {}); + }, 500); return () => clearTimeout(handle); } - }, [proxiesData, mode, refreshProxy]); + }, [proxiesData, proxyHydration, mode, refreshProxy]); + + useEffect(() => { + if (proxyHydration !== "snapshot") return; + + const handle = setTimeout(() => { + void refreshProxy().catch(() => {}); + }, 1800); + + return () => clearTimeout(handle); + }, [proxyHydration, refreshProxy]); // 链式代理模式节点自动计算延迟 useEffect(() => { @@ -147,7 +122,7 @@ export const useRenderList = ( // 设置组监听器,当有延迟更新时自动刷新 const groupListener = () => { console.log("[ChainMode] 延迟更新,刷新UI"); - refreshProxy(); + void refreshProxy().catch(() => {}); }; delayManager.setGroupListener("chain-mode", groupListener); @@ -188,9 +163,12 @@ export const useRenderList = ( // 链式代理模式下,显示代理组和其节点 if (isChainMode && runtimeConfig && mode === "rule") { // 使用正常的规则模式代理组 - const allGroups = proxiesData.groups.length - ? proxiesData.groups - : [proxiesData.global!]; + const chainGroups = proxiesData.groups ?? []; + const allGroups = chainGroups.length + ? chainGroups + : proxiesData.global + ? [proxiesData.global] + : []; // 如果选择了特定代理组,只显示该组的节点 if (selectedGroup) { @@ -282,7 +260,7 @@ export const useRenderList = ( }); // 创建一个虚拟的组来容纳所有节点 - const virtualGroup: ProxyGroup = { + const virtualGroup: RenderGroup = { name: "All Proxies", type: "Selector", udp: false, @@ -340,7 +318,7 @@ export const useRenderList = ( }); // 创建一个虚拟的组来容纳所有节点 - const virtualGroup: ProxyGroup = { + const virtualGroup: RenderGroup = { name: "All Proxies", type: "Selector", udp: false, @@ -380,12 +358,15 @@ export const useRenderList = ( // 正常模式的渲染逻辑 const useRule = mode === "rule" || mode === "script"; - const renderGroups = - useRule && proxiesData.groups.length - ? proxiesData.groups - : [proxiesData.global!]; + const renderGroups = (() => { + const groups = proxiesData.groups ?? []; + if (useRule && groups.length) { + return groups; + } + return proxiesData.global ? [proxiesData.global] : groups; + })(); - const retList = renderGroups.flatMap((group: ProxyGroup) => { + const retList = renderGroups.flatMap((group: RenderGroup) => { const headState = headStates[group.name] || DEFAULT_STATE; const ret: IRenderItem[] = [ { diff --git a/src/hooks/use-current-proxy.ts b/src/hooks/use-current-proxy.ts index 7d352326..0c7108ff 100644 --- a/src/hooks/use-current-proxy.ts +++ b/src/hooks/use-current-proxy.ts @@ -2,12 +2,6 @@ import { useMemo } from "react"; import { useAppData } from "@/providers/app-data-context"; -// 定义代理组类型 -interface ProxyGroup { - name: string; - now: string; -} - // 获取当前代理节点信息的自定义Hook export const useCurrentProxy = () => { // 从AppDataProvider获取数据 @@ -37,15 +31,15 @@ export const useCurrentProxy = () => { "自动选择", ]; const primaryGroup = - groups.find((group: ProxyGroup) => + groups.find((group) => primaryKeywords.some((keyword) => group.name.toLowerCase().includes(keyword.toLowerCase()), ), - ) || groups.filter((g: ProxyGroup) => g.name !== "GLOBAL")[0]; + ) || groups.find((group) => group.name !== "GLOBAL"); if (primaryGroup) { primaryGroupName = primaryGroup.name; - currentName = primaryGroup.now; + currentName = primaryGroup.now ?? currentName; } } diff --git a/src/providers/app-data-context.ts b/src/providers/app-data-context.ts index 7b7244ab..9fb2e1ec 100644 --- a/src/providers/app-data-context.ts +++ b/src/providers/app-data-context.ts @@ -6,8 +6,11 @@ import { RuleProvider, } from "tauri-plugin-mihomo-api"; +import { ProxiesView } from "@/services/cmds"; + export interface AppDataContextType { - proxies: any; + proxies: ProxiesView | null; + proxyHydration: "none" | "snapshot" | "live"; clashConfig: BaseConfig; rules: Rule[]; sysproxy: any; diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index c71528c4..e5ed9e8c 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -1,4 +1,4 @@ -import { listen } from "@tauri-apps/api/event"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import React, { useCallback, useEffect, useMemo } from "react"; import useSWR from "swr"; import { @@ -9,13 +9,19 @@ import { import { useVerge } from "@/hooks/use-verge"; import { - calcuProxies, calcuProxyProviders, getAppUptime, getRunningMode, + readProfileFile, getSystemProxy, } from "@/services/cmds"; -import { SWR_DEFAULTS, SWR_REALTIME, SWR_SLOW_POLL } from "@/services/config"; +import { SWR_DEFAULTS, SWR_SLOW_POLL } from "@/services/config"; +import { + ensureProxyEventBridge, + fetchLiveProxies, + useProxyStore, +} from "@/stores/proxy-store"; +import { createProxySnapshotFromProfile } from "@/utils/proxy-snapshot"; import { AppDataContext, AppDataContextType } from "./app-data-context"; @@ -26,15 +32,9 @@ export const AppDataProvider = ({ children: React.ReactNode; }) => { const { verge } = useVerge(); - - const { data: proxiesData, mutate: refreshProxy } = useSWR( - "getProxies", - calcuProxies, - { - ...SWR_REALTIME, - onError: (err) => console.warn("[DataProvider] Proxy fetch failed:", err), - }, - ); + const proxyView = useProxyStore((state) => state.data); + const proxyHydration = useProxyStore((state) => state.hydration); + const setProxySnapshot = useProxyStore((state) => state.setSnapshot); const { data: clashConfig, mutate: refreshClashConfig } = useSWR( "getClashConfig", @@ -60,9 +60,56 @@ export const AppDataProvider = ({ SWR_DEFAULTS, ); + const seedProxySnapshot = useCallback( + async (profileId: string) => { + if (!profileId) return; + + try { + const yamlContent = await readProfileFile(profileId); + const snapshot = createProxySnapshotFromProfile(yamlContent); + if (!snapshot) return; + + setProxySnapshot(snapshot, profileId); + } catch (error) { + console.warn( + "[DataProvider] Failed to seed proxy snapshot from profile:", + error, + ); + } + }, + [setProxySnapshot], + ); + + useEffect(() => { + let unlistenBridge: UnlistenFn | null = null; + + ensureProxyEventBridge() + .then((unlisten) => { + unlistenBridge = unlisten; + }) + .catch((error) => { + console.error( + "[DataProvider] Failed to establish proxy bridge:", + error, + ); + }); + + fetchLiveProxies().catch((error) => { + console.error("[DataProvider] Initial proxy fetch failed:", error); + }); + + return () => { + if (unlistenBridge) { + void unlistenBridge(); + } + }; + }, []); + useEffect(() => { let lastProfileId: string | null = null; - let lastUpdateTime = 0; + let lastProfileChangeTime = 0; + let lastProxyRefreshTime = 0; + let lastClashRefreshTime = 0; const refreshThrottle = 800; let isUnmounted = false; @@ -109,21 +156,52 @@ export const AppDataProvider = ({ scheduledTimeouts.clear(); }; + const queueProxyRefresh = ( + reason: string, + delays: number[] = [0, 250, 1000, 2000], + ) => { + delays.forEach((delay) => { + scheduleTimeout(() => { + fetchLiveProxies().catch((error) => + console.warn( + `[DataProvider] Proxy refresh failed (${reason}, +${delay}ms):`, + error, + ), + ); + }, delay); + }); + }; + const handleProfileChanged = (event: { payload: string }) => { const newProfileId = event.payload; const now = Date.now(); - if ( lastProfileId === newProfileId && - now - lastUpdateTime < refreshThrottle + now - lastProfileChangeTime < refreshThrottle ) { return; } lastProfileId = newProfileId; - lastUpdateTime = now; + lastProfileChangeTime = now; + lastProxyRefreshTime = 0; + lastClashRefreshTime = 0; + + void seedProxySnapshot(newProfileId); scheduleTimeout(() => { + queueProxyRefresh("profile-change"); + void refreshProxyProviders() + .then(() => { + queueProxyRefresh("profile-change:providers", [0, 500, 1500]); + }) + .catch((error) => + console.warn( + "[DataProvider] Proxy providers refresh failed after profile change:", + error, + ), + ); + refreshRules().catch((error) => console.warn("[DataProvider] Rules refresh failed:", error), ); @@ -133,27 +211,45 @@ export const AppDataProvider = ({ }, 200); }; + const handleProfileUpdateCompleted = (_: { payload: { uid: string } }) => { + const now = Date.now(); + lastProxyRefreshTime = now; + lastClashRefreshTime = now; + scheduleTimeout(() => { + queueProxyRefresh("profile-update-completed"); + void refreshProxyProviders() + .then(() => { + queueProxyRefresh( + "profile-update-completed:providers", + [0, 500, 1500], + ); + }) + .catch((error) => + console.warn( + "[DataProvider] Proxy providers refresh failed after profile update completed:", + error, + ), + ); + }, 120); + }; + const handleRefreshClash = () => { const now = Date.now(); - if (now - lastUpdateTime <= refreshThrottle) return; + if (now - lastClashRefreshTime <= refreshThrottle) return; - lastUpdateTime = now; + lastClashRefreshTime = now; scheduleTimeout(() => { - refreshProxy().catch((error) => - console.error("[DataProvider] Proxy refresh failed:", error), - ); + queueProxyRefresh("refresh-clash"); }, 200); }; const handleRefreshProxy = () => { const now = Date.now(); - if (now - lastUpdateTime <= refreshThrottle) return; + if (now - lastProxyRefreshTime <= refreshThrottle) return; - lastUpdateTime = now; + lastProxyRefreshTime = now; scheduleTimeout(() => { - refreshProxy().catch((error) => - console.warn("[DataProvider] Proxy refresh failed:", error), - ); + queueProxyRefresh("refresh-proxy"); }, 200); }; @@ -163,7 +259,12 @@ export const AppDataProvider = ({ "profile-changed", handleProfileChanged, ); + const unlistenProfileCompleted = await listen<{ + uid: string; + }>("profile-update-completed", handleProfileUpdateCompleted); + registerCleanup(unlistenProfile); + registerCleanup(unlistenProfileCompleted); } catch (error) { console.error("[AppDataProvider] 监听 Profile 事件失败:", error); } @@ -188,6 +289,16 @@ export const AppDataProvider = ({ const fallbackHandlers: Array<[string, EventListener]> = [ ["verge://refresh-clash-config", handleRefreshClash], ["verge://refresh-proxy-config", handleRefreshProxy], + [ + "profile-update-completed", + ((event: Event) => { + const payload = (event as CustomEvent<{ uid: string }>) + .detail ?? { + uid: "", + }; + handleProfileUpdateCompleted({ payload }); + }) as EventListener, + ], ]; fallbackHandlers.forEach(([eventName, handler]) => { @@ -220,7 +331,12 @@ export const AppDataProvider = ({ ); } }; - }, [refreshProxy, refreshRules, refreshRuleProviders]); + }, [ + refreshProxyProviders, + refreshRules, + refreshRuleProviders, + seedProxySnapshot, + ]); const { data: sysproxy, mutate: refreshSysproxy } = useSWR( "getSystemProxy", @@ -243,7 +359,7 @@ export const AppDataProvider = ({ // 提供统一的刷新方法 const refreshAll = useCallback(async () => { await Promise.all([ - refreshProxy(), + fetchLiveProxies(), refreshClashConfig(), refreshRules(), refreshSysproxy(), @@ -251,7 +367,6 @@ export const AppDataProvider = ({ refreshRuleProviders(), ]); }, [ - refreshProxy, refreshClashConfig, refreshRules, refreshSysproxy, @@ -294,7 +409,8 @@ export const AppDataProvider = ({ return { // 数据 - proxies: proxiesData, + proxies: proxyView, + proxyHydration, clashConfig, rules: rulesData?.rules || [], sysproxy, @@ -308,7 +424,7 @@ export const AppDataProvider = ({ systemProxyAddress: calculateSystemProxyAddress(), // 刷新方法 - refreshProxy, + refreshProxy: fetchLiveProxies, refreshClashConfig, refreshRules, refreshSysproxy, @@ -317,7 +433,8 @@ export const AppDataProvider = ({ refreshAll, } as AppDataContextType; }, [ - proxiesData, + proxyView, + proxyHydration, clashConfig, rulesData, sysproxy, @@ -326,7 +443,6 @@ export const AppDataProvider = ({ proxyProviders, ruleProviders, verge, - refreshProxy, refreshClashConfig, refreshRules, refreshSysproxy, diff --git a/src/services/cmds.ts b/src/services/cmds.ts index e1e686bd..49f767a1 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -4,6 +4,19 @@ import { getProxies, getProxyProviders } from "tauri-plugin-mihomo-api"; import { showNotice } from "@/services/noticeService"; +export type ProxyProviderRecord = Record< + string, + IProxyProviderItem | undefined +>; + +let cachedProxyProviders: ProxyProviderRecord | null = null; + +export const getCachedProxyProviders = () => cachedProxyProviders; + +export const setCachedProxyProviders = (record: ProxyProviderRecord | null) => { + cachedProxyProviders = record; +}; + export async function copyClashEnv() { return invoke("copy_clash_env"); } @@ -113,27 +126,29 @@ export async function syncTrayProxySelection() { return invoke("sync_tray_proxy_selection"); } -export async function calcuProxies(): Promise<{ +export interface ProxiesView { global: IProxyGroupItem; direct: IProxyItem; groups: IProxyGroupItem[]; records: Record; proxies: IProxyItem[]; -}> { - const [proxyResponse, providerResponse] = await Promise.all([ - getProxies(), - calcuProxyProviders(), - ]); +} +export function buildProxyView( + proxyResponse: Awaited>, + providerRecord?: ProxyProviderRecord | null, +): ProxiesView { const proxyRecord = proxyResponse.proxies; - const providerRecord = providerResponse; // provider name map - const providerMap = Object.fromEntries( - Object.entries(providerRecord).flatMap(([provider, item]) => - item!.proxies.map((p) => [p.name, { ...p, provider }]), - ), - ); + const providerMap = providerRecord + ? Object.fromEntries( + Object.entries(providerRecord).flatMap(([provider, item]) => { + if (!item) return []; + return item.proxies.map((p) => [p.name, { ...p, provider }]); + }), + ) + : {}; // compatible with proxy-providers const generateItem = (name: string) => { @@ -207,16 +222,56 @@ export async function calcuProxies(): Promise<{ }; } +export async function calcuProxies(): Promise { + const proxyResponse = await getProxies(); + + let providerRecord = cachedProxyProviders; + if (!providerRecord) { + try { + providerRecord = await calcuProxyProviders(); + } catch (error) { + console.warn("[calcuProxies] 代理提供者加载失败:", error); + } + } + + return buildProxyView(proxyResponse, providerRecord); +} + export async function calcuProxyProviders() { const providers = await getProxyProviders(); - return Object.fromEntries( - Object.entries(providers.providers) - .sort() - .filter( - ([_, item]) => - item?.vehicleType === "HTTP" || item?.vehicleType === "File", - ), - ); + const mappedEntries = Object.entries(providers.providers) + .sort() + .filter( + ([, item]) => + item?.vehicleType === "HTTP" || item?.vehicleType === "File", + ) + .map(([name, item]) => { + if (!item) return [name, undefined] as const; + + const subscriptionInfo = + item.subscriptionInfo && typeof item.subscriptionInfo === "object" + ? { + Upload: item.subscriptionInfo.Upload ?? 0, + Download: item.subscriptionInfo.Download ?? 0, + Total: item.subscriptionInfo.Total ?? 0, + Expire: item.subscriptionInfo.Expire ?? 0, + } + : undefined; + + const normalized: IProxyProviderItem = { + name: item.name, + type: item.type, + proxies: item.proxies ?? [], + updatedAt: item.updatedAt ?? "", + vehicleType: item.vehicleType ?? "", + subscriptionInfo, + }; + return [name, normalized] as const; + }); + + const mapped = Object.fromEntries(mappedEntries) as ProxyProviderRecord; + cachedProxyProviders = mapped; + return mapped; } export async function getClashLogs() { diff --git a/src/stores/proxy-store.ts b/src/stores/proxy-store.ts new file mode 100644 index 00000000..98f51a83 --- /dev/null +++ b/src/stores/proxy-store.ts @@ -0,0 +1,136 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { getProxies } from "tauri-plugin-mihomo-api"; +import { create } from "zustand"; + +import { + ProxiesView, + ProxyProviderRecord, + buildProxyView, + calcuProxies, + getCachedProxyProviders, + setCachedProxyProviders, +} from "@/services/cmds"; +type ProxyHydration = "none" | "snapshot" | "live"; +type RawProxiesResponse = Awaited>; + +export interface ProxiesUpdatedPayload { + proxies: RawProxiesResponse; + providers?: ProxyProviderRecord | Record | null; + emittedAt?: number; + profileId?: string | null; +} + +interface ProxyStoreState { + data: ProxiesView | null; + hydration: ProxyHydration; + lastUpdated: number | null; + lastProfileId: string | null; + setSnapshot: (snapshot: ProxiesView, profileId: string) => void; + setLive: (payload: ProxiesUpdatedPayload) => void; + reset: () => void; +} + +const normalizeProviderPayload = ( + raw: ProxiesUpdatedPayload["providers"], +): ProxyProviderRecord | null => { + if (!raw || typeof raw !== "object") return null; + + const entries = Object.entries(raw as Record).map( + ([name, value]) => { + if (!value) return [name, undefined] as const; + + const normalized: IProxyProviderItem = { + name: value.name ?? name, + type: value.type ?? "", + proxies: Array.isArray(value.proxies) ? value.proxies : [], + updatedAt: value.updatedAt ?? "", + vehicleType: value.vehicleType ?? "", + subscriptionInfo: value.subscriptionInfo + ? { + Upload: Number(value.subscriptionInfo.Upload ?? 0), + Download: Number(value.subscriptionInfo.Download ?? 0), + Total: Number(value.subscriptionInfo.Total ?? 0), + Expire: Number(value.subscriptionInfo.Expire ?? 0), + } + : undefined, + }; + + return [name, normalized] as const; + }, + ); + + return Object.fromEntries(entries) as ProxyProviderRecord; +}; + +export const useProxyStore = create((set, get) => ({ + data: null, + hydration: "none", + lastUpdated: null, + lastProfileId: null, + setSnapshot(snapshot, profileId) { + set({ + data: snapshot, + hydration: "snapshot", + lastUpdated: Date.now(), + lastProfileId: profileId, + }); + }, + setLive(payload) { + const state = get(); + const emittedAt = payload.emittedAt ?? Date.now(); + + if (state.lastUpdated && emittedAt <= state.lastUpdated) { + return; + } + + const providersRecord = + normalizeProviderPayload(payload.providers) ?? getCachedProxyProviders(); + + if (providersRecord) { + setCachedProxyProviders(providersRecord); + } + + const view = buildProxyView(payload.proxies, providersRecord); + const nextProfileId = payload.profileId ?? state.lastProfileId; + + set({ + data: view, + hydration: "live", + lastUpdated: emittedAt, + lastProfileId: nextProfileId ?? null, + }); + }, + reset() { + set({ + data: null, + hydration: "none", + lastUpdated: null, + lastProfileId: null, + }); + }, +})); + +let bridgePromise: Promise | null = null; + +export const ensureProxyEventBridge = () => { + if (!bridgePromise) { + bridgePromise = listen( + "proxies-updated", + (event) => { + useProxyStore.getState().setLive(event.payload); + }, + ); + } + + return bridgePromise; +}; + +export const fetchLiveProxies = async () => { + const view = await calcuProxies(); + useProxyStore.setState((state) => ({ + data: view, + hydration: "live", + lastUpdated: Date.now(), + lastProfileId: state.lastProfileId, + })); +}; diff --git a/src/utils/proxy-snapshot.ts b/src/utils/proxy-snapshot.ts new file mode 100644 index 00000000..d8fdefd4 --- /dev/null +++ b/src/utils/proxy-snapshot.ts @@ -0,0 +1,202 @@ +import yaml from "js-yaml"; + +const createProxyItem = ( + name: string, + partial: Partial = {}, +): IProxyItem => ({ + name, + type: partial.type ?? "unknown", + udp: partial.udp ?? false, + xudp: partial.xudp ?? false, + tfo: partial.tfo ?? false, + mptcp: partial.mptcp ?? false, + smux: partial.smux ?? false, + history: [], + provider: partial.provider, + testUrl: partial.testUrl, +}); + +const createGroupItem = ( + name: string, + all: IProxyItem[], + partial: Partial = {}, +): IProxyGroupItem => { + const rest = { ...partial } as Partial; + delete (rest as Partial).all; + const base = createProxyItem(name, rest); + return { + ...base, + all, + now: partial.now ?? base.now, + }; +}; + +const ensureProxyItem = ( + map: Map, + name: string, + source?: Partial, +) => { + const key = String(name); + if (map.has(key)) return map.get(key)!; + const item = createProxyItem(key, source); + map.set(key, item); + return item; +}; + +const parseProxyEntry = (entry: any): IProxyItem | null => { + if (!entry || typeof entry !== "object") return null; + const name = entry.name || entry.uid || entry.id; + if (!name) return null; + return createProxyItem(String(name), { + type: entry.type ? String(entry.type) : undefined, + udp: Boolean(entry.udp), + xudp: Boolean(entry.xudp), + tfo: Boolean(entry.tfo), + mptcp: Boolean(entry.mptcp), + smux: Boolean(entry.smux), + testUrl: entry.test_url || entry.testUrl, + }); +}; + +const parseProxyGroup = ( + entry: any, + proxyMap: Map, +): IProxyGroupItem | null => { + if (!entry || typeof entry !== "object") return null; + const name = entry.name; + if (!name) return null; + + const rawList: unknown[] = Array.isArray(entry.proxies) + ? entry.proxies + : Array.isArray(entry.use) + ? entry.use + : []; + + const uniqueNames = Array.from( + new Set( + rawList + .filter( + (item): item is string => + typeof item === "string" && item.trim().length > 0, + ) + .map((item) => item.trim()), + ), + ); + + const all = uniqueNames.map((proxyName) => + ensureProxyItem(proxyMap, proxyName), + ); + + return createGroupItem(String(name), all, { + type: entry.type ? String(entry.type) : "Selector", + provider: entry.provider, + testUrl: entry.testUrl || entry.test_url, + }); +}; + +const mapRecords = ( + proxies: Map, + groups: IProxyGroupItem[], + extra: IProxyItem[] = [], +): Record => { + const result: Record = {}; + proxies.forEach((item, key) => { + result[key] = item; + }); + groups.forEach((group) => { + result[group.name] = group as unknown as IProxyItem; + }); + extra.forEach((item) => { + result[item.name] = item; + }); + return result; +}; + +export const createProxySnapshotFromProfile = ( + yamlContent: string, +): { + global: IProxyGroupItem; + direct: IProxyItem; + groups: IProxyGroupItem[]; + records: Record; + proxies: IProxyItem[]; +} | null => { + let parsed: any; + try { + parsed = yaml.load(yamlContent); + } catch (error) { + console.warn("[ProxySnapshot] Failed to parse YAML:", error); + return null; + } + + if (!parsed || typeof parsed !== "object") { + return null; + } + + const proxyMap = new Map(); + + if (Array.isArray((parsed as any).proxies)) { + for (const entry of (parsed as any).proxies) { + const item = parseProxyEntry(entry); + if (item) { + proxyMap.set(item.name, item); + } + } + } + + const proxyProviders = (parsed as any)["proxy-providers"]; + if (proxyProviders && typeof proxyProviders === "object") { + for (const key of Object.keys(proxyProviders)) { + const provider = proxyProviders[key]; + if (provider && Array.isArray(provider.proxies)) { + provider.proxies + .filter( + (proxyName: unknown): proxyName is string => + typeof proxyName === "string", + ) + .forEach((proxyName: string) => ensureProxyItem(proxyMap, proxyName)); + } + } + } + + const groups: IProxyGroupItem[] = []; + if (Array.isArray((parsed as any)["proxy-groups"])) { + for (const entry of (parsed as any)["proxy-groups"]) { + const groupItem = parseProxyGroup(entry, proxyMap); + if (groupItem) { + groups.push(groupItem); + } + } + } + + const direct = createProxyItem("DIRECT", { type: "Direct" }); + const reject = createProxyItem("REJECT", { type: "Reject" }); + + ensureProxyItem(proxyMap, direct.name, direct); + ensureProxyItem(proxyMap, reject.name, reject); + + let global = groups.find((group) => group.name === "GLOBAL"); + if (!global) { + const globalRefs = groups.flatMap((group) => + group.all.map((proxy) => proxy.name), + ); + const unique = Array.from(new Set(globalRefs)); + const all = unique.map((name) => ensureProxyItem(proxyMap, name)); + global = createGroupItem("GLOBAL", all, { type: "Selector" }); + groups.unshift(global); + } + + const proxies = Array.from(proxyMap.values()).filter( + (item) => !groups.some((group) => group.name === item.name), + ); + + const records = mapRecords(proxyMap, groups, [direct, reject]); + + return { + global, + direct, + groups, + records, + proxies, + }; +};