refactor(profiles): introduce a state machine
This commit is contained in:
@@ -29,8 +29,10 @@ import { listen, TauriEvent } from "@tauri-apps/api/event";
|
||||
import { readText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useLockFn } from "ahooks";
|
||||
import type { TFunction } from "i18next";
|
||||
import { throttle } from "lodash-es";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
import useSWR, { mutate } from "swr";
|
||||
@@ -70,33 +72,169 @@ const debugProfileSwitch = (action: string, profile: string, extra?: any) => {
|
||||
);
|
||||
};
|
||||
|
||||
// 检查请求是否已过期
|
||||
const isRequestOutdated = (
|
||||
currentSequence: number,
|
||||
requestSequenceRef: any,
|
||||
profile: string,
|
||||
) => {
|
||||
if (currentSequence !== requestSequenceRef.current) {
|
||||
debugProfileSwitch(
|
||||
"REQUEST_OUTDATED",
|
||||
profile,
|
||||
`当前序列号: ${currentSequence}, 最新序列号: ${requestSequenceRef.current}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
type SwitchRequest = {
|
||||
profile: string;
|
||||
notifySuccess: boolean;
|
||||
};
|
||||
|
||||
// 检查是否被中断
|
||||
const isOperationAborted = (
|
||||
abortController: AbortController,
|
||||
profile: string,
|
||||
) => {
|
||||
if (abortController.signal.aborted) {
|
||||
debugProfileSwitch("OPERATION_ABORTED", profile);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
interface ProfileSwitchMachineOptions {
|
||||
getCurrentProfileId: () => string | undefined;
|
||||
patchProfiles: (
|
||||
value: Partial<IProfilesConfig>,
|
||||
signal: AbortSignal,
|
||||
) => Promise<boolean>;
|
||||
mutateLogs: () => Promise<void>;
|
||||
closeAllConnections: () => Promise<void>;
|
||||
activateSelected: () => Promise<void>;
|
||||
setActivatings: Dispatch<SetStateAction<string[]>>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
const useProfileSwitchMachine = ({
|
||||
getCurrentProfileId,
|
||||
patchProfiles,
|
||||
mutateLogs,
|
||||
closeAllConnections,
|
||||
activateSelected,
|
||||
setActivatings,
|
||||
t,
|
||||
}: ProfileSwitchMachineOptions) => {
|
||||
const switchingProfileRef = useRef<string | null>(null);
|
||||
const pendingProfileRef = useRef<SwitchRequest | null>(null);
|
||||
const processingQueueRef = useRef(false);
|
||||
const requestSequenceRef = useRef(0);
|
||||
|
||||
const cleanupSwitchState = useCallback(
|
||||
(profile: string, sequence: number) => {
|
||||
setActivatings((prev) => prev.filter((id) => id !== profile));
|
||||
if (switchingProfileRef.current === profile) {
|
||||
switchingProfileRef.current = null;
|
||||
}
|
||||
debugProfileSwitch("SWITCH_END", profile, `序列号: ${sequence}`);
|
||||
},
|
||||
[setActivatings],
|
||||
);
|
||||
|
||||
const runProfileSwitch = useCallback(
|
||||
async (request: SwitchRequest) => {
|
||||
const { profile, notifySuccess } = request;
|
||||
const currentSequence = ++requestSequenceRef.current;
|
||||
debugProfileSwitch("NEW_REQUEST", profile, `序列号: ${currentSequence}`);
|
||||
|
||||
switchingProfileRef.current = profile;
|
||||
debugProfileSwitch("SWITCH_START", profile, `序列号: ${currentSequence}`);
|
||||
|
||||
setActivatings((prev) => {
|
||||
if (prev.includes(profile)) return prev;
|
||||
return [...prev, profile];
|
||||
});
|
||||
|
||||
const isOutdated = () => currentSequence !== requestSequenceRef.current;
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
if (isOutdated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestPromise = patchProfiles(
|
||||
{ current: profile },
|
||||
controller.signal,
|
||||
);
|
||||
const success = await requestPromise;
|
||||
|
||||
if (isOutdated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mutateLogs();
|
||||
await closeAllConnections();
|
||||
|
||||
if (notifySuccess && success) {
|
||||
showNotice("success", t("Profile Switched"), 1000);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (isOutdated()) return;
|
||||
activateSelected().catch((err) =>
|
||||
console.warn("Failed to activate selected proxies:", err),
|
||||
);
|
||||
}, 50);
|
||||
} catch (error: any) {
|
||||
console.error(`[Profile] 切换失败:`, error);
|
||||
showNotice("error", error?.message || error.toString(), 4000);
|
||||
} finally {
|
||||
cleanupSwitchState(profile, currentSequence);
|
||||
}
|
||||
},
|
||||
[
|
||||
activateSelected,
|
||||
cleanupSwitchState,
|
||||
closeAllConnections,
|
||||
mutateLogs,
|
||||
patchProfiles,
|
||||
setActivatings,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const processSwitchQueue = useCallback(() => {
|
||||
if (processingQueueRef.current) return;
|
||||
|
||||
const run = async () => {
|
||||
processingQueueRef.current = true;
|
||||
try {
|
||||
while (true) {
|
||||
const next = pendingProfileRef.current;
|
||||
if (!next) break;
|
||||
pendingProfileRef.current = null;
|
||||
await runProfileSwitch(next);
|
||||
}
|
||||
} finally {
|
||||
processingQueueRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
console.error("[Profile] 处理切换队列失败:", error);
|
||||
});
|
||||
}, [runProfileSwitch]);
|
||||
|
||||
const requestSwitch = useCallback(
|
||||
(profile: string, notifySuccess: boolean) => {
|
||||
const currentProfileId = getCurrentProfileId();
|
||||
if (currentProfileId === profile && !notifySuccess) {
|
||||
debugProfileSwitch("ALREADY_CURRENT_IGNORED", profile);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSwitching = switchingProfileRef.current;
|
||||
|
||||
if (currentSwitching === profile) {
|
||||
debugProfileSwitch("DUPLICATE_SWITCH_BLOCKED", profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSwitching && currentSwitching !== profile) {
|
||||
pendingProfileRef.current = { profile, notifySuccess };
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingProfileRef.current?.profile === profile) {
|
||||
pendingProfileRef.current = { profile, notifySuccess };
|
||||
return;
|
||||
}
|
||||
|
||||
pendingProfileRef.current = { profile, notifySuccess };
|
||||
processSwitchQueue();
|
||||
},
|
||||
[getCurrentProfileId, processSwitchQueue],
|
||||
);
|
||||
|
||||
return {
|
||||
requestSwitch,
|
||||
switchingProfileRef,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeProfileUrl = (value?: string) => {
|
||||
@@ -220,57 +358,6 @@ const ProfilePage = () => {
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
// 防止重复切换
|
||||
const switchingProfileRef = useRef<string | null>(null);
|
||||
|
||||
// 支持中断当前切换操作
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// 只处理最新的切换请求
|
||||
const requestSequenceRef = useRef<number>(0);
|
||||
|
||||
// 待处理请求跟踪,取消排队的请求
|
||||
const pendingRequestRef = useRef<Promise<any> | null>(null);
|
||||
|
||||
// 处理profile切换中断
|
||||
const handleProfileInterrupt = useCallback(
|
||||
(previousSwitching: string, newProfile: string) => {
|
||||
debugProfileSwitch(
|
||||
"INTERRUPT_PREVIOUS",
|
||||
previousSwitching,
|
||||
`被 ${newProfile} 中断`,
|
||||
);
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
debugProfileSwitch("ABORT_CONTROLLER_TRIGGERED", previousSwitching);
|
||||
}
|
||||
|
||||
if (pendingRequestRef.current) {
|
||||
debugProfileSwitch("CANCEL_PENDING_REQUEST", previousSwitching);
|
||||
}
|
||||
|
||||
setActivatings((prev) => prev.filter((id) => id !== previousSwitching));
|
||||
showNotice(
|
||||
"info",
|
||||
`${t("Profile switch interrupted by new selection")}: ${previousSwitching} → ${newProfile}`,
|
||||
3000,
|
||||
);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// 清理切换状态
|
||||
const cleanupSwitchState = useCallback(
|
||||
(profile: string, sequence: number) => {
|
||||
setActivatings((prev) => prev.filter((id) => id !== profile));
|
||||
switchingProfileRef.current = null;
|
||||
abortControllerRef.current = null;
|
||||
pendingRequestRef.current = null;
|
||||
debugProfileSwitch("SWITCH_END", profile, `序列号: ${sequence}`);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
@@ -453,6 +540,29 @@ const ProfilePage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentProfileId = useCallback(
|
||||
() => profiles.current ?? undefined,
|
||||
[profiles],
|
||||
);
|
||||
|
||||
const memoizedMutateLogs = useCallback(async () => {
|
||||
await mutateLogs();
|
||||
}, [mutateLogs]);
|
||||
|
||||
const closeConnections = useCallback(async () => {
|
||||
await closeAllConnections();
|
||||
}, []);
|
||||
|
||||
const { requestSwitch, switchingProfileRef } = useProfileSwitchMachine({
|
||||
getCurrentProfileId,
|
||||
patchProfiles,
|
||||
mutateLogs: memoizedMutateLogs,
|
||||
closeAllConnections: closeConnections,
|
||||
activateSelected,
|
||||
setActivatings,
|
||||
t,
|
||||
});
|
||||
|
||||
// 强化的刷新策略
|
||||
const performRobustRefresh = async (
|
||||
importVerifier: ImportLandingVerifier,
|
||||
@@ -539,168 +649,7 @@ const ProfilePage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const executeBackgroundTasks = useCallback(
|
||||
async (
|
||||
profile: string,
|
||||
sequence: number,
|
||||
abortController: AbortController,
|
||||
) => {
|
||||
try {
|
||||
if (
|
||||
sequence === requestSequenceRef.current &&
|
||||
switchingProfileRef.current === profile &&
|
||||
!abortController.signal.aborted
|
||||
) {
|
||||
await activateSelected();
|
||||
console.log(`[Profile] 后台处理完成,序列号: ${sequence}`);
|
||||
} else {
|
||||
debugProfileSwitch(
|
||||
"BACKGROUND_TASK_SKIPPED",
|
||||
profile,
|
||||
`序列号过期或被中断: ${sequence} vs ${requestSequenceRef.current}`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn("Failed to activate selected proxies:", err);
|
||||
}
|
||||
},
|
||||
[activateSelected],
|
||||
);
|
||||
|
||||
const activateProfile = useCallback(
|
||||
async (profile: string, notifySuccess: boolean) => {
|
||||
if (profiles.current === profile && !notifySuccess) {
|
||||
console.log(
|
||||
`[Profile] 目标profile ${profile} 已经是当前配置,跳过切换`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSequence = ++requestSequenceRef.current;
|
||||
debugProfileSwitch("NEW_REQUEST", profile, `序列号: ${currentSequence}`);
|
||||
|
||||
// 处理中断逻辑
|
||||
const previousSwitching = switchingProfileRef.current;
|
||||
if (previousSwitching && previousSwitching !== profile) {
|
||||
handleProfileInterrupt(previousSwitching, profile);
|
||||
}
|
||||
|
||||
// 防止重复切换同一个profile
|
||||
if (switchingProfileRef.current === profile) {
|
||||
debugProfileSwitch("DUPLICATE_SWITCH_BLOCKED", profile);
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化切换状态
|
||||
switchingProfileRef.current = profile;
|
||||
debugProfileSwitch("SWITCH_START", profile, `序列号: ${currentSequence}`);
|
||||
|
||||
const currentAbortController = new AbortController();
|
||||
abortControllerRef.current = currentAbortController;
|
||||
|
||||
setActivatings((prev) => {
|
||||
if (prev.includes(profile)) return prev;
|
||||
return [...prev, profile];
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[Profile] 开始切换到: ${profile},序列号: ${currentSequence}`,
|
||||
);
|
||||
|
||||
// 检查请求有效性
|
||||
if (
|
||||
isRequestOutdated(currentSequence, requestSequenceRef, profile) ||
|
||||
isOperationAborted(currentAbortController, profile)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行切换请求
|
||||
const requestPromise = patchProfiles(
|
||||
{ current: profile },
|
||||
currentAbortController.signal,
|
||||
);
|
||||
pendingRequestRef.current = requestPromise;
|
||||
|
||||
const success = await requestPromise;
|
||||
|
||||
if (pendingRequestRef.current === requestPromise) {
|
||||
pendingRequestRef.current = null;
|
||||
}
|
||||
|
||||
// 再次检查有效性
|
||||
if (
|
||||
isRequestOutdated(currentSequence, requestSequenceRef, profile) ||
|
||||
isOperationAborted(currentAbortController, profile)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 完成切换
|
||||
await mutateLogs();
|
||||
closeAllConnections();
|
||||
|
||||
if (notifySuccess && success) {
|
||||
showNotice("success", t("Profile Switched"), 1000);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Profile] 切换到 ${profile} 完成,序列号: ${currentSequence},开始后台处理`,
|
||||
);
|
||||
|
||||
// 延迟执行后台任务
|
||||
setTimeout(
|
||||
() =>
|
||||
executeBackgroundTasks(
|
||||
profile,
|
||||
currentSequence,
|
||||
currentAbortController,
|
||||
),
|
||||
50,
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (pendingRequestRef.current) {
|
||||
pendingRequestRef.current = null;
|
||||
}
|
||||
|
||||
// 检查是否因为中断或过期而出错
|
||||
if (
|
||||
isOperationAborted(currentAbortController, profile) ||
|
||||
isRequestOutdated(currentSequence, requestSequenceRef, profile)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`[Profile] 切换失败:`, err);
|
||||
showNotice("error", err?.message || err.toString(), 4000);
|
||||
} finally {
|
||||
// 只有当前profile仍然是正在切换的profile且序列号匹配时才清理状态
|
||||
if (
|
||||
switchingProfileRef.current === profile &&
|
||||
currentSequence === requestSequenceRef.current
|
||||
) {
|
||||
cleanupSwitchState(profile, currentSequence);
|
||||
} else {
|
||||
debugProfileSwitch(
|
||||
"CLEANUP_SKIPPED",
|
||||
profile,
|
||||
`序列号不匹配或已被接管: ${currentSequence} vs ${requestSequenceRef.current}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
profiles,
|
||||
patchProfiles,
|
||||
mutateLogs,
|
||||
t,
|
||||
executeBackgroundTasks,
|
||||
handleProfileInterrupt,
|
||||
cleanupSwitchState,
|
||||
],
|
||||
);
|
||||
const onSelect = async (current: string, force: boolean) => {
|
||||
const onSelect = (current: string, force: boolean) => {
|
||||
// 阻止重复点击或已激活的profile
|
||||
if (switchingProfileRef.current === current) {
|
||||
debugProfileSwitch("DUPLICATE_CLICK_IGNORED", current);
|
||||
@@ -712,17 +661,15 @@ const ProfilePage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await activateProfile(current, true);
|
||||
requestSwitch(current, true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (current) {
|
||||
mutateProfiles();
|
||||
await activateProfile(current, false);
|
||||
}
|
||||
})();
|
||||
}, [current, activateProfile, mutateProfiles]);
|
||||
if (current) {
|
||||
mutateProfiles();
|
||||
requestSwitch(current, false);
|
||||
}
|
||||
}, [current, mutateProfiles, requestSwitch]);
|
||||
|
||||
const onEnhance = useLockFn(async (notifySuccess: boolean) => {
|
||||
if (switchingProfileRef.current) {
|
||||
@@ -945,16 +892,6 @@ const ProfilePage = () => {
|
||||
};
|
||||
}, [mutateProfiles]);
|
||||
|
||||
// 组件卸载时清理中断控制器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
debugProfileSwitch("COMPONENT_UNMOUNT_CLEANUP", "all");
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
full
|
||||
|
||||
Reference in New Issue
Block a user