Revert "refactor: profile switch (#5197)"

This reverts commit c2dcd86722.
This commit is contained in:
Tunglies
2025-10-30 18:11:04 +08:00
parent 928f226d10
commit a869dbb441
36 changed files with 1257 additions and 5894 deletions
+1 -8
View File
@@ -6,15 +6,8 @@ import {
RuleProvider,
} from "tauri-plugin-mihomo-api";
import { ProxiesView, type ProfileSwitchStatus } from "@/services/cmds";
export interface AppDataContextType {
proxies: ProxiesView | null;
proxyHydration: "none" | "snapshot" | "live";
proxyTargetProfileId: string | null;
proxyDisplayProfileId: string | null;
isProxyRefreshPending: boolean;
switchStatus: ProfileSwitchStatus | null;
proxies: any;
clashConfig: BaseConfig;
rules: Rule[];
sysproxy: any;
+138 -581
View File
@@ -1,6 +1,6 @@
import { listen } from "@tauri-apps/api/event";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import useSWR, { mutate as globalMutate } from "swr";
import React, { useCallback, useEffect, useMemo } from "react";
import useSWR from "swr";
import {
getBaseConfig,
getRuleProviders,
@@ -9,53 +9,31 @@ import {
import { useVerge } from "@/hooks/use-verge";
import {
calcuProxies,
calcuProxyProviders,
getAppUptime,
getProfileSwitchStatus,
getProfileSwitchEvents,
getProfiles as fetchProfilesConfig,
getRunningMode,
readProfileFile,
getSystemProxy,
type ProxiesView,
type ProfileSwitchStatus,
type SwitchResultStatus,
} from "@/services/cmds";
import { SWR_DEFAULTS, SWR_SLOW_POLL } from "@/services/config";
import { useProfileStore } from "@/stores/profile-store";
import {
applyLiveProxyPayload,
fetchLiveProxies,
type ProxiesUpdatedPayload,
useProxyStore,
} from "@/stores/proxy-store";
import { createProxySnapshotFromProfile } from "@/utils/proxy-snapshot";
import { SWR_DEFAULTS, SWR_REALTIME, SWR_SLOW_POLL } from "@/services/config";
import { AppDataContext, AppDataContextType } from "./app-data-context";
// Global app data provider
// 全局数据提供者组件
export const AppDataProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const { verge } = useVerge();
const applyProfileSwitchResult = useProfileStore(
(state) => state.applySwitchResult,
);
const commitProfileSnapshot = useProfileStore(
(state) => state.commitHydrated,
);
const setSwitchEventSeq = useProfileStore((state) => state.setLastEventSeq);
const proxyView = useProxyStore((state) => state.data);
const proxyHydration = useProxyStore((state) => state.hydration);
const proxyProfileId = useProxyStore((state) => state.lastProfileId);
const pendingProxyProfileId = useProxyStore(
(state) => state.pendingProfileId,
);
const setProxySnapshot = useProxyStore((state) => state.setSnapshot);
const clearPendingProxyProfile = useProxyStore(
(state) => state.clearPendingProfile,
const { data: proxiesData, mutate: refreshProxy } = useSWR(
"getProxies",
calcuProxies,
{
...SWR_REALTIME,
onError: (err) => console.warn("[DataProvider] Proxy fetch failed:", err),
},
);
const { data: clashConfig, mutate: refreshClashConfig } = useSWR(
@@ -82,259 +60,25 @@ export const AppDataProvider = ({
SWR_DEFAULTS,
);
const { data: switchStatus, mutate: mutateSwitchStatus } =
useSWR<ProfileSwitchStatus>(
"getProfileSwitchStatus",
getProfileSwitchStatus,
{
refreshInterval: (status) =>
status && (status.isSwitching || (status.queue?.length ?? 0) > 0)
? 400
: 4000,
dedupingInterval: 200,
},
);
const isUnmountedRef = useRef(false);
// Keep track of pending timers so we can cancel them on unmount and avoid stray updates.
const scheduledTimeoutsRef = useRef<Set<number>>(new Set());
// Shared metadata to dedupe switch events coming from both polling and subscriptions.
const switchMetaRef = useRef<{
pendingProfileId: string | null;
lastResultTaskId: number | null;
}>({
pendingProfileId: null,
lastResultTaskId: null,
});
const switchEventSeqRef = useRef(0);
const profileChangeMetaRef = useRef<{
lastProfileId: string | null;
lastEventTs: number;
}>({
lastProfileId: null,
lastEventTs: 0,
});
const lastClashRefreshAtRef = useRef(0);
const PROFILE_EVENT_DEDUP_MS = 400;
const CLASH_REFRESH_DEDUP_MS = 300;
// Thin wrapper around setTimeout that no-ops once the provider unmounts.
const scheduleTimeout = useCallback(
(callback: () => void | Promise<void>, delay: number) => {
if (isUnmountedRef.current) return -1;
const timeoutId = window.setTimeout(() => {
scheduledTimeoutsRef.current.delete(timeoutId);
if (!isUnmountedRef.current) {
void callback();
}
}, delay);
scheduledTimeoutsRef.current.add(timeoutId);
return timeoutId;
},
[],
);
const clearAllTimeouts = useCallback(() => {
scheduledTimeoutsRef.current.forEach((timeoutId) =>
clearTimeout(timeoutId),
);
scheduledTimeoutsRef.current.clear();
}, []);
// Delay live proxy refreshes slightly so we don't hammer Mihomo while a switch is still applying.
const queueProxyRefresh = useCallback(
(reason: string, delay = 1500) => {
scheduleTimeout(() => {
fetchLiveProxies().catch((error) =>
console.warn(
`[DataProvider] Proxy refresh failed (${reason}, fallback):`,
error,
),
);
}, delay);
},
[scheduleTimeout],
);
// Prime the proxy store with the static selections from the profile YAML before live data arrives.
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],
);
const handleSwitchResult = useCallback(
(result: SwitchResultStatus) => {
// Ignore duplicate notifications for the same switch execution.
const meta = switchMetaRef.current;
if (result.taskId === meta.lastResultTaskId) {
return;
}
meta.lastResultTaskId = result.taskId;
// Optimistically update the SWR cache so the UI shows the new profile immediately.
void globalMutate(
"getProfiles",
(current?: IProfilesConfig | null) => {
if (!current || !result.success) {
return current;
}
if (current.current === result.profileId) {
return current;
}
return {
...current,
current: result.profileId,
};
},
false,
);
applyProfileSwitchResult(result);
if (!result.success) {
clearPendingProxyProfile();
}
if (result.success && result.cancelled !== true) {
// Once the backend settles, refresh all dependent data in the background.
scheduleTimeout(() => {
void Promise.allSettled([
fetchProfilesConfig().then((data) => {
commitProfileSnapshot(data);
globalMutate("getProfiles", data, false);
}),
fetchLiveProxies(),
refreshProxyProviders(),
refreshRules(),
refreshRuleProviders(),
]).catch((error) => {
console.warn(
"[DataProvider] Background refresh after profile switch failed:",
error,
);
});
}, 100);
}
void mutateSwitchStatus((current) => {
if (!current) {
return current;
}
const filteredQueue = current.queue.filter(
(task) => task.taskId !== result.taskId,
);
const active =
current.active && current.active.taskId === result.taskId
? null
: current.active;
const isSwitching = filteredQueue.length > 0;
return {
...current,
active,
queue: filteredQueue,
isSwitching,
lastResult: result,
};
}, false);
},
[
scheduleTimeout,
refreshProxyProviders,
refreshRules,
refreshRuleProviders,
mutateSwitchStatus,
applyProfileSwitchResult,
commitProfileSnapshot,
clearPendingProxyProfile,
],
);
useEffect(() => {
isUnmountedRef.current = false;
return () => {
isUnmountedRef.current = true;
clearAllTimeouts();
};
}, [clearAllTimeouts]);
let lastProfileId: string | null = null;
let lastUpdateTime = 0;
const refreshThrottle = 800;
useEffect(() => {
if (!switchStatus) {
return;
}
const meta = switchMetaRef.current;
const nextTarget =
switchStatus.active?.profileId ??
(switchStatus.queue.length > 0 ? switchStatus.queue[0].profileId : null);
if (nextTarget && nextTarget !== meta.pendingProfileId) {
meta.pendingProfileId = nextTarget;
void seedProxySnapshot(nextTarget);
} else if (!nextTarget) {
meta.pendingProfileId = null;
}
const lastResult = switchStatus.lastResult ?? null;
if (lastResult) {
handleSwitchResult(lastResult);
}
}, [switchStatus, seedProxySnapshot, handleSwitchResult]);
useEffect(() => {
let disposed = false;
const pollEvents = async () => {
if (disposed) {
return;
}
try {
const events = await getProfileSwitchEvents(switchEventSeqRef.current);
if (events.length > 0) {
switchEventSeqRef.current = events[events.length - 1].sequence;
setSwitchEventSeq(switchEventSeqRef.current);
events.forEach((event) => handleSwitchResult(event.result));
}
} catch (error) {
console.warn("[DataProvider] Failed to poll switch events:", error);
} finally {
if (!disposed) {
const nextDelay =
switchStatus &&
(switchStatus.isSwitching || (switchStatus.queue?.length ?? 0) > 0)
? 250
: 1000;
scheduleTimeout(pollEvents, nextDelay);
}
}
};
scheduleTimeout(pollEvents, 0);
return () => {
disposed = true;
};
}, [scheduleTimeout, handleSwitchResult, switchStatus, setSwitchEventSeq]);
useEffect(() => {
let isUnmounted = false;
const scheduledTimeouts = new Set<number>();
const cleanupFns: Array<() => void> = [];
const registerCleanup = (fn: () => void) => {
cleanupFns.push(fn);
if (isUnmounted) {
try {
fn();
} catch (error) {
console.error("[DataProvider] Immediate cleanup failed:", error);
}
} else {
cleanupFns.push(fn);
}
};
const addWindowListener = (eventName: string, handler: EventListener) => {
@@ -343,319 +87,140 @@ export const AppDataProvider = ({
return () => window.removeEventListener(eventName, handler);
};
const runProfileChangedPipeline = (
profileId: string | null,
source: "tauri" | "window",
const scheduleTimeout = (
callback: () => void | Promise<void>,
delay: number,
) => {
if (isUnmounted) return -1;
const timeoutId = window.setTimeout(() => {
scheduledTimeouts.delete(timeoutId);
if (!isUnmounted) {
void callback();
}
}, delay);
scheduledTimeouts.add(timeoutId);
return timeoutId;
};
const clearAllTimeouts = () => {
scheduledTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
scheduledTimeouts.clear();
};
const handleProfileChanged = (event: { payload: string }) => {
const newProfileId = event.payload;
const now = Date.now();
const meta = profileChangeMetaRef.current;
if (
meta.lastProfileId === profileId &&
now - meta.lastEventTs < PROFILE_EVENT_DEDUP_MS
lastProfileId === newProfileId &&
now - lastUpdateTime < refreshThrottle
) {
return;
}
meta.lastProfileId = profileId;
meta.lastEventTs = now;
if (profileId) {
void seedProxySnapshot(profileId);
}
queueProxyRefresh(`profile-changed-${source}`, 500);
lastProfileId = newProfileId;
lastUpdateTime = now;
scheduleTimeout(() => {
void fetchProfilesConfig()
.then((data) => {
commitProfileSnapshot(data);
globalMutate("getProfiles", data, false);
})
.catch((error) =>
console.warn(
"[AppDataProvider] Failed to refresh profiles after profile change:",
error,
),
);
void refreshProxyProviders().catch((error) =>
console.warn(
"[AppDataProvider] Proxy providers refresh failed after profile change:",
error,
),
refreshRules().catch((error) =>
console.warn("[DataProvider] Rules refresh failed:", error),
);
void refreshRules().catch((error) =>
console.warn(
"[AppDataProvider] Rules refresh failed after profile change:",
error,
),
);
void refreshRuleProviders().catch((error) =>
console.warn(
"[AppDataProvider] Rule providers refresh failed after profile change:",
error,
),
refreshRuleProviders().catch((error) =>
console.warn("[DataProvider] Rule providers refresh failed:", error),
);
}, 200);
};
const handleProfileChanged = (event: { payload: string }) => {
runProfileChangedPipeline(event.payload ?? null, "tauri");
};
const runRefreshClashPipeline = (source: "tauri" | "window") => {
const handleRefreshClash = () => {
const now = Date.now();
if (now - lastClashRefreshAtRef.current < CLASH_REFRESH_DEDUP_MS) {
return;
}
lastClashRefreshAtRef.current = now;
if (now - lastUpdateTime <= refreshThrottle) return;
lastUpdateTime = now;
scheduleTimeout(() => {
void refreshClashConfig().catch((error) =>
console.warn(
"[AppDataProvider] Clash config refresh failed after backend update:",
error,
),
refreshProxy().catch((error) =>
console.error("[DataProvider] Proxy refresh failed:", error),
);
void refreshRules().catch((error) =>
console.warn(
"[AppDataProvider] Rules refresh failed after backend update:",
error,
),
);
void refreshRuleProviders().catch((error) =>
console.warn(
"[AppDataProvider] Rule providers refresh failed after backend update:",
error,
),
);
void refreshProxyProviders().catch((error) =>
console.warn(
"[AppDataProvider] Proxy providers refresh failed after backend update:",
error,
),
);
}, 0);
queueProxyRefresh(`refresh-clash-config-${source}`, 400);
}, 200);
};
const handleProfileUpdateCompleted = (_: { payload: { uid: string } }) => {
queueProxyRefresh("profile-update-completed", 3000);
if (!isUnmountedRef.current) {
scheduleTimeout(() => {
void refreshProxyProviders().catch((error) =>
console.warn(
"[DataProvider] Proxy providers refresh failed after profile update completed:",
error,
),
);
}, 0);
}
};
const handleRefreshProxy = () => {
const now = Date.now();
if (now - lastUpdateTime <= refreshThrottle) return;
const isProxiesPayload = (
value: unknown,
): value is ProxiesUpdatedPayload => {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<ProxiesUpdatedPayload>;
return candidate.proxies !== undefined && candidate.proxies !== null;
};
const handleProxiesUpdatedPayload = (
rawPayload: unknown,
source: "tauri" | "window",
) => {
if (!isProxiesPayload(rawPayload)) {
console.warn(
`[AppDataProvider] Ignored ${source} proxies-updated payload`,
rawPayload,
lastUpdateTime = now;
scheduleTimeout(() => {
refreshProxy().catch((error) =>
console.warn("[DataProvider] Proxy refresh failed:", error),
);
queueProxyRefresh(`proxies-updated-${source}-invalid`, 500);
return;
}, 200);
};
const initializeListeners = async () => {
try {
const unlistenProfile = await listen<string>(
"profile-changed",
handleProfileChanged,
);
registerCleanup(unlistenProfile);
} catch (error) {
console.error("[AppDataProvider] 监听 Profile 事件失败:", error);
}
try {
applyLiveProxyPayload(rawPayload);
} catch (error) {
console.warn(
`[AppDataProvider] Failed to apply ${source} proxies-updated payload`,
error,
const unlistenClash = await listen(
"verge://refresh-clash-config",
handleRefreshClash,
);
queueProxyRefresh(`proxies-updated-${source}-apply-failed`, 500);
const unlistenProxy = await listen(
"verge://refresh-proxy-config",
handleRefreshProxy,
);
registerCleanup(() => {
unlistenClash();
unlistenProxy();
});
} catch (error) {
console.warn("[AppDataProvider] 设置 Tauri 事件监听器失败:", error);
const fallbackHandlers: Array<[string, EventListener]> = [
["verge://refresh-clash-config", handleRefreshClash],
["verge://refresh-proxy-config", handleRefreshProxy],
];
fallbackHandlers.forEach(([eventName, handler]) => {
registerCleanup(addWindowListener(eventName, handler));
});
}
};
listen<{ uid: string }>(
"profile-update-completed",
handleProfileUpdateCompleted,
)
.then(registerCleanup)
.catch((error) =>
console.error(
"[AppDataProvider] failed to attach profile update listeners:",
error,
),
);
listen<string>("profile-changed", handleProfileChanged)
.then(registerCleanup)
.catch((error) =>
console.error(
"[AppDataProvider] failed to attach profile-changed listener:",
error,
),
);
listen<ProxiesUpdatedPayload>("proxies-updated", (event) => {
handleProxiesUpdatedPayload(event.payload, "tauri");
})
.then(registerCleanup)
.catch((error) =>
console.error(
"[AppDataProvider] failed to attach proxies-updated listener:",
error,
),
);
listen("verge://refresh-clash-config", () => {
runRefreshClashPipeline("tauri");
})
.then(registerCleanup)
.catch((error) =>
console.error(
"[AppDataProvider] failed to attach refresh-clash-config listener:",
error,
),
);
listen("verge://refresh-proxy-config", () => {
queueProxyRefresh("refresh-proxy-config-tauri", 500);
})
.then(registerCleanup)
.catch((error) =>
console.error(
"[AppDataProvider] failed to attach refresh-proxy-config listener:",
error,
),
);
const fallbackHandlers: Array<[string, EventListener]> = [
[
"profile-update-completed",
((event: Event) => {
const payload = (event as CustomEvent<{ uid: string }>).detail ?? {
uid: "",
};
handleProfileUpdateCompleted({ payload });
}) as EventListener,
],
[
"profile-changed",
((event: Event) => {
const payload = (event as CustomEvent<string | null>).detail ?? null;
runProfileChangedPipeline(payload, "window");
}) as EventListener,
],
[
"proxies-updated",
((event: Event) => {
const payload = (event as CustomEvent<ProxiesUpdatedPayload>).detail;
handleProxiesUpdatedPayload(payload, "window");
}) as EventListener,
],
[
"verge://refresh-clash-config",
(() => {
runRefreshClashPipeline("window");
}) as EventListener,
],
[
"verge://refresh-proxy-config",
(() => {
queueProxyRefresh("refresh-proxy-config-window", 500);
}) as EventListener,
],
];
fallbackHandlers.forEach(([eventName, handler]) => {
registerCleanup(addWindowListener(eventName, handler));
});
void initializeListeners();
return () => {
cleanupFns.forEach((fn) => {
isUnmounted = true;
clearAllTimeouts();
const errors: Error[] = [];
cleanupFns.splice(0).forEach((fn) => {
try {
fn();
} catch (error) {
console.error("[AppDataProvider] cleanup error:", error);
errors.push(
error instanceof Error ? error : new Error(String(error)),
);
}
});
if (errors.length > 0) {
console.error(
`[DataProvider] ${errors.length} errors during cleanup:`,
errors,
);
}
};
}, [
commitProfileSnapshot,
queueProxyRefresh,
refreshClashConfig,
refreshProxyProviders,
refreshRuleProviders,
refreshRules,
scheduleTimeout,
seedProxySnapshot,
]);
const switchTargetProfileId =
switchStatus?.active?.profileId ??
(switchStatus && switchStatus.queue.length > 0
? switchStatus.queue[0].profileId
: null);
const proxyTargetProfileId =
switchTargetProfileId ?? pendingProxyProfileId ?? proxyProfileId ?? null;
const displayProxyStateRef = useRef<{
view: ProxiesView | null;
profileId: string | null;
}>({
view: proxyView,
profileId: proxyTargetProfileId,
});
const currentDisplay = displayProxyStateRef.current;
if (!proxyView) {
if (
currentDisplay.view !== null ||
currentDisplay.profileId !== proxyTargetProfileId
) {
displayProxyStateRef.current = {
view: null,
profileId: proxyTargetProfileId,
};
}
} else if (proxyHydration === "live") {
if (
currentDisplay.view !== proxyView ||
currentDisplay.profileId !== proxyTargetProfileId
) {
displayProxyStateRef.current = {
view: proxyView,
profileId: proxyTargetProfileId,
};
}
} else if (!currentDisplay.view) {
displayProxyStateRef.current = {
view: proxyView,
profileId: proxyTargetProfileId,
};
}
const displayProxyState = displayProxyStateRef.current;
const proxyDisplayProfileId = displayProxyState.profileId;
const proxiesForRender = displayProxyState.view ?? proxyView;
const isProxyRefreshPending =
(switchStatus?.isSwitching ?? false) ||
proxyHydration !== "live" ||
proxyTargetProfileId !== proxyDisplayProfileId;
}, [refreshProxy, refreshRules, refreshRuleProviders]);
const { data: sysproxy, mutate: refreshSysproxy } = useSWR(
"getSystemProxy",
@@ -675,10 +240,10 @@ export const AppDataProvider = ({
errorRetryCount: 1,
});
// Provide unified refresh method
// 提供统一的刷新方法
const refreshAll = useCallback(async () => {
await Promise.all([
fetchLiveProxies(),
refreshProxy(),
refreshClashConfig(),
refreshRules(),
refreshSysproxy(),
@@ -686,6 +251,7 @@ export const AppDataProvider = ({
refreshRuleProviders(),
]);
}, [
refreshProxy,
refreshClashConfig,
refreshRules,
refreshSysproxy,
@@ -693,22 +259,22 @@ export const AppDataProvider = ({
refreshRuleProviders,
]);
// Aggregate data into context value
// 聚合所有数据
const value = useMemo(() => {
// Compute the system proxy address
// 计算系统代理地址
const calculateSystemProxyAddress = () => {
if (!verge || !clashConfig) return "-";
const isPacMode = verge.proxy_auto_config ?? false;
if (isPacMode) {
// PAC mode: display the desired proxy address
// PAC模式:显示我们期望设置的代理地址
const proxyHost = verge.proxy_host || "127.0.0.1";
const proxyPort =
verge.verge_mixed_port || clashConfig.mixedPort || 7897;
return `${proxyHost}:${proxyPort}`;
} else {
// HTTP proxy mode: prefer system address, fallback to desired address if invalid
// HTTP代理模式:优先使用系统地址,但如果格式不正确则使用期望地址
const systemServer = sysproxy?.server;
if (
systemServer &&
@@ -717,7 +283,7 @@ export const AppDataProvider = ({
) {
return systemServer;
} else {
// System address invalid: fallback to desired proxy address
// 系统地址无效,返回期望的代理地址
const proxyHost = verge.proxy_host || "127.0.0.1";
const proxyPort =
verge.verge_mixed_port || clashConfig.mixedPort || 7897;
@@ -727,27 +293,22 @@ export const AppDataProvider = ({
};
return {
// Data
proxies: proxiesForRender,
proxyHydration,
proxyTargetProfileId,
proxyDisplayProfileId,
isProxyRefreshPending,
switchStatus: switchStatus ?? null,
// 数据
proxies: proxiesData,
clashConfig,
rules: rulesData?.rules || [],
sysproxy,
runningMode,
uptime: uptimeData || 0,
// Provider data
// 提供者数据
proxyProviders: proxyProviders || {},
ruleProviders: ruleProviders?.providers || {},
systemProxyAddress: calculateSystemProxyAddress(),
// Refresh helpers
refreshProxy: fetchLiveProxies,
// 刷新方法
refreshProxy,
refreshClashConfig,
refreshRules,
refreshSysproxy,
@@ -756,12 +317,7 @@ export const AppDataProvider = ({
refreshAll,
} as AppDataContextType;
}, [
proxiesForRender,
proxyHydration,
proxyTargetProfileId,
proxyDisplayProfileId,
isProxyRefreshPending,
switchStatus,
proxiesData,
clashConfig,
rulesData,
sysproxy,
@@ -770,6 +326,7 @@ export const AppDataProvider = ({
proxyProviders,
ruleProviders,
verge,
refreshProxy,
refreshClashConfig,
refreshRules,
refreshSysproxy,