diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 69df3a31d..66d60ef6e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -408,6 +408,18 @@ pub fn run() { /// Handle window destroyed events pub fn handle_window_destroyed() { + AsyncHandler::spawn(|| async { + if let Err(e) = handle::Handle::mihomo() + .await + .clear_all_ws_connections() + .await + { + logging!(warn, Type::Window, "清理 WebSocket 连接失败: {}", e); + } else { + logging!(info, Type::Window, "WebSocket 连接已清理"); + } + }); + #[cfg(target_os = "macos")] { use crate::core::hotkey::SystemHotkey; diff --git a/src/hooks/use-cleanup.ts b/src/hooks/use-cleanup.ts new file mode 100644 index 000000000..3766cec8c --- /dev/null +++ b/src/hooks/use-cleanup.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef } from "react"; + +/** + * 资源清理 Hook + * 用于在组件卸载或窗口关闭时统一清理资源 + */ +export const useCleanup = () => { + const cleanupFnsRef = useRef void>>(new Set()); + + const registerCleanup = (fn: () => void) => { + cleanupFnsRef.current.add(fn); + return () => { + cleanupFnsRef.current.delete(fn); + }; + }; + + const cleanup = () => { + cleanupFnsRef.current.forEach((fn) => { + try { + fn(); + } catch (error) { + console.error("[资源清理] 清理失败:", error); + } + }); + cleanupFnsRef.current.clear(); + }; + + useEffect(() => { + return () => { + cleanup(); + }; + }, []); + + return { registerCleanup, cleanup }; +}; diff --git a/src/hooks/use-connection-data.ts b/src/hooks/use-connection-data.ts index e4f0b14c6..f599882b5 100644 --- a/src/hooks/use-connection-data.ts +++ b/src/hooks/use-connection-data.ts @@ -87,7 +87,12 @@ export const useConnectionData = () => { } return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } ws.current?.close(); + ws.current = null; }; }, { diff --git a/src/hooks/use-listen.ts b/src/hooks/use-listen.ts index 4cd0e7222..f2bf4e906 100644 --- a/src/hooks/use-listen.ts +++ b/src/hooks/use-listen.ts @@ -13,9 +13,26 @@ export const useListen = () => { unlistenFns.current.push(unlisten); return unlisten; }; + const removeAllListeners = () => { - unlistenFns.current.forEach((unlisten) => unlisten()); - unlistenFns.current = []; + const errors: Error[] = []; + + unlistenFns.current.forEach((unlisten) => { + try { + unlisten(); + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))); + } + }); + + if (errors.length > 0) { + console.warn( + `[useListen] 清理监听器时发生 ${errors.length} 个错误`, + errors, + ); + } + + unlistenFns.current.length = 0; }; const setupCloseListener = async function () { @@ -26,6 +43,7 @@ export const useListen = () => { return { addListener, + removeAllListeners, setupCloseListener, }; }; diff --git a/src/hooks/use-log-data.ts b/src/hooks/use-log-data.ts index f7f90d7b4..67f4ac190 100644 --- a/src/hooks/use-log-data.ts +++ b/src/hooks/use-log-data.ts @@ -120,7 +120,12 @@ export const useLogData = () => { } return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } ws.current?.close(); + ws.current = null; }; }, { diff --git a/src/hooks/use-memory-data.ts b/src/hooks/use-memory-data.ts index a20b95800..12b25d340 100644 --- a/src/hooks/use-memory-data.ts +++ b/src/hooks/use-memory-data.ts @@ -63,7 +63,12 @@ export const useMemoryData = () => { } return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } ws.current?.close(); + ws.current = null; }; }, { diff --git a/src/hooks/use-traffic-data.ts b/src/hooks/use-traffic-data.ts index 73cc40b83..1e386da79 100644 --- a/src/hooks/use-traffic-data.ts +++ b/src/hooks/use-traffic-data.ts @@ -69,7 +69,12 @@ export const useTrafficData = () => { } return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } ws.current?.close(); + ws.current = null; }; }, { diff --git a/src/main.tsx b/src/main.tsx index 9aba83d7d..62e3c4e27 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -47,41 +47,30 @@ document.addEventListener("keydown", (event) => { } }); -const initializeApp = async () => { - try { - await initializeLanguage("zh"); +const initializeApp = () => { + const contexts = [ + , + , + , + ]; - const contexts = [ - , - , - , - ]; - - const root = createRoot(container); - root.render( - - - - - - - - - - - , - ); - } catch (error) { - console.error("[main.tsx] 应用初始化失败:", error); - const root = createRoot(container); - root.render( -
- 应用初始化失败: {error instanceof Error ? error.message : String(error)} -
, - ); - } + const root = createRoot(container); + root.render( + + + + + + + + + + + , + ); }; +initializeLanguage("zh").catch(console.error); initializeApp(); // 错误处理 @@ -94,7 +83,7 @@ window.addEventListener("unhandledrejection", (event) => { }); // 页面关闭/刷新事件 -window.addEventListener("beforeunload", async () => { - // 强制清理所有 WebSocket 实例, 防止内存泄漏 - await MihomoWebSocket.cleanupAll(); +window.addEventListener("beforeunload", () => { + // 同步清理所有 WebSocket 实例, 防止内存泄漏 + MihomoWebSocket.cleanupAll(); }); diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 1af59d0f6..f7605824d 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -1,13 +1,10 @@ import { List, Paper, SvgIcon, ThemeProvider } from "@mui/material"; -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; -import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Outlet, useNavigate } from "react-router"; -import { SWRConfig, mutate } from "swr"; +import { SWRConfig } from "swr"; import iconDark from "@/assets/image/icon_dark.svg?react"; import iconLight from "@/assets/image/icon_light.svg?react"; @@ -21,229 +18,60 @@ import { UpdateButton } from "@/components/layout/update-button"; import { useCustomTheme } from "@/components/layout/use-custom-theme"; import { useConnectionData } from "@/hooks/use-connection-data"; import { useI18n } from "@/hooks/use-i18n"; -import { useListen } from "@/hooks/use-listen"; import { useLogData } from "@/hooks/use-log-data"; import { useMemoryData } from "@/hooks/use-memory-data"; import { useTrafficData } from "@/hooks/use-traffic-data"; import { useVerge } from "@/hooks/use-verge"; import { useWindowDecorations } from "@/hooks/use-window"; -import { getAxios } from "@/services/api"; -import { showNotice } from "@/services/noticeService"; import { useThemeMode } from "@/services/states"; import getSystem from "@/utils/get-system"; +import { handleNoticeMessage } from "./_layout/notificationHandlers"; +import { useAppInitialization } from "./_layout/useAppInitialization"; +import { useLayoutEvents } from "./_layout/useLayoutEvents"; +import { useLoadingOverlay } from "./_layout/useLoadingOverlay"; import { navItems } from "./_routers"; import "dayjs/locale/ru"; import "dayjs/locale/zh-cn"; -const appWindow = getCurrentWebviewWindow(); export const portableFlag = false; dayjs.extend(relativeTime); const OS = getSystem(); -// 通知处理函数 -const handleNoticeMessage = ( - status: string, - msg: string, - t: (key: string) => string, - navigate: (path: string, options?: any) => void, -) => { - console.log("[通知监听 V2] 收到消息:", status, msg); - - switch (status) { - case "import_sub_url::ok": - navigate("/profile", { state: { current: msg } }); - showNotice("success", t("Import Subscription Successful")); - break; - case "import_sub_url::error": - navigate("/profile"); - showNotice("error", msg); - break; - case "set_config::error": - showNotice("error", msg); - break; - case "update_with_clash_proxy": - showNotice( - "success", - `${t("Update with Clash proxy successfully")} ${msg}`, - ); - break; - case "update_retry_with_clash": - showNotice("info", t("Update failed, retrying with Clash proxy...")); - break; - case "update_failed_even_with_clash": - showNotice( - "error", - `${t("Update failed even with Clash proxy")}: ${msg}`, - ); - break; - case "update_failed": - showNotice("error", msg); - break; - case "config_validate::boot_error": - showNotice("error", `${t("Boot Config Validation Failed")} ${msg}`); - break; - case "config_validate::core_change": - showNotice( - "error", - `${t("Core Change Config Validation Failed")} ${msg}`, - ); - break; - case "config_validate::error": - showNotice("error", `${t("Config Validation Failed")} ${msg}`); - break; - case "config_validate::process_terminated": - showNotice("error", t("Config Validation Process Terminated")); - break; - case "config_validate::stdout_error": - showNotice("error", `${t("Config Validation Failed")} ${msg}`); - break; - case "config_validate::script_error": - showNotice("error", `${t("Script File Error")} ${msg}`); - break; - case "config_validate::script_syntax_error": - showNotice("error", `${t("Script Syntax Error")} ${msg}`); - break; - case "config_validate::script_missing_main": - showNotice("error", `${t("Script Missing Main")} ${msg}`); - break; - case "config_validate::file_not_found": - showNotice("error", `${t("File Not Found")} ${msg}`); - break; - case "config_validate::yaml_syntax_error": - showNotice("error", `${t("YAML Syntax Error")} ${msg}`); - break; - case "config_validate::yaml_read_error": - showNotice("error", `${t("YAML Read Error")} ${msg}`); - break; - case "config_validate::yaml_mapping_error": - showNotice("error", `${t("YAML Mapping Error")} ${msg}`); - break; - case "config_validate::yaml_key_error": - showNotice("error", `${t("YAML Key Error")} ${msg}`); - break; - case "config_validate::yaml_error": - showNotice("error", `${t("YAML Error")} ${msg}`); - break; - case "config_validate::merge_syntax_error": - showNotice("error", `${t("Merge File Syntax Error")} ${msg}`); - break; - case "config_validate::merge_mapping_error": - showNotice("error", `${t("Merge File Mapping Error")} ${msg}`); - break; - case "config_validate::merge_key_error": - showNotice("error", `${t("Merge File Key Error")} ${msg}`); - break; - case "config_validate::merge_error": - showNotice("error", `${t("Merge File Error")} ${msg}`); - break; - case "config_core::change_success": - showNotice("success", `${t("Core Changed Successfully")}: ${msg}`); - break; - case "config_core::change_error": - showNotice("error", `${t("Failed to Change Core")}: ${msg}`); - break; - default: // Optional: Log unhandled statuses - console.warn(`[通知监听 V2] 未处理的状态: ${status}`); - break; - } -}; - const Layout = () => { - useTrafficData(); - useMemoryData(); - useConnectionData(); - useLogData(); + const trafficData = useTrafficData(); + const memoryData = useMemoryData(); + const connectionData = useConnectionData(); + const logData = useLogData(); + const mode = useThemeMode(); - const isDark = mode === "light" ? false : true; + const isDark = mode !== "light"; const { t } = useTranslation(); const { theme } = useCustomTheme(); const { verge } = useVerge(); const { language } = verge ?? {}; const { switchLanguage } = useI18n(); const navigate = useNavigate(); - const { addListener } = useListen(); - const initRef = useRef(false); - const overlayRemovedRef = useRef(false); const themeReady = useMemo(() => Boolean(theme), [theme]); const windowControls = useRef(null); const { decorated } = useWindowDecorations(); - const customTitlebar = useMemo(() => { - console.debug( - "[Layout] Titlebar rendering - decorated:", - decorated, - "| showing:", - !decorated, - "| theme mode:", - mode, - ); - if (!decorated) { - return ( + const customTitlebar = useMemo( + () => + !decorated ? (
- ); - } - return null; - }, [decorated, mode]); + ) : null, + [decorated], + ); - useEffect(() => { - if (!themeReady || overlayRemovedRef.current) { - return; - } - - let fadeTimer: number | null = null; - let retryTimer: number | null = null; - let attempts = 0; - const maxAttempts = 50; - let stopped = false; - - const tryRemoveOverlay = () => { - if (stopped || overlayRemovedRef.current) { - return; - } - - const overlay = document.getElementById("initial-loading-overlay"); - if (overlay) { - overlayRemovedRef.current = true; - overlay.style.opacity = "0"; - overlay.style.pointerEvents = "none"; - - fadeTimer = window.setTimeout(() => { - try { - overlay.remove(); - } catch (error) { - console.warn("[Layout] Failed to remove loading overlay:", error); - } - }, 300); - return; - } - - if (attempts < maxAttempts) { - attempts += 1; - retryTimer = window.setTimeout(tryRemoveOverlay, 100); - } else { - console.warn("[Layout] Loading overlay not found after retries"); - } - }; - - tryRemoveOverlay(); - - return () => { - stopped = true; - if (fadeTimer) { - window.clearTimeout(fadeTimer); - } - if (retryTimer) { - window.clearTimeout(retryTimer); - } - }; - }, [themeReady]); + useLoadingOverlay(themeReady); + useAppInitialization(); const handleNotice = useCallback( (payload: [string, string]) => { @@ -251,275 +79,14 @@ const Layout = () => { try { handleNoticeMessage(status, msg, t, navigate); } catch (error) { - console.error("[Layout] 处理通知消息失败:", error); + console.error("[通知处理] 失败:", error); } }, [t, navigate], ); - // 设置监听 - useEffect(() => { - const unlisteners: Array<() => void> = []; - let disposed = false; + useLayoutEvents(handleNotice); - const register = ( - maybeUnlisten: void | (() => void) | Promise void)>, - ) => { - if (!maybeUnlisten) { - return; - } - if (typeof maybeUnlisten === "function") { - unlisteners.push(maybeUnlisten); - return; - } - maybeUnlisten - .then((unlisten) => { - if (!unlisten) { - return; - } - if (disposed) { - unlisten(); - } else { - unlisteners.push(unlisten); - } - }) - .catch((error) => { - console.error("[Layout] 注册事件监听失败", error); - }); - }; - - register( - addListener("verge://refresh-clash-config", async () => { - await getAxios(true); - mutate("getProxies"); - mutate("getVersion"); - mutate("getClashConfig"); - mutate("getProxyProviders"); - }), - ); - - register( - addListener("verge://refresh-verge-config", () => { - mutate("getVergeConfig"); - mutate("getSystemProxy"); - mutate("getAutotemProxy"); - mutate("getRunningMode"); - mutate("isServiceAvailable"); - }), - ); - - register( - addListener("verge://notice-message", ({ payload }) => - handleNotice(payload as [string, string]), - ), - ); - - register( - (async () => { - const [hideUnlisten, showUnlisten] = await Promise.all([ - listen("verge://hide-window", () => appWindow.hide()), - listen("verge://show-window", () => appWindow.show()), - ]); - return () => { - hideUnlisten(); - showUnlisten(); - }; - })(), - ); - - return () => { - disposed = true; - unlisteners.forEach((unlisten) => { - try { - unlisten(); - } catch (error) { - console.error("[Layout] 清理事件监听器失败", error); - } - }); - }; - }, [addListener, handleNotice]); - - useEffect(() => { - if (initRef.current) { - console.log("[Layout] 初始化代码已执行过,跳过"); - return; - } - console.log("[Layout] 开始执行初始化代码"); - initRef.current = true; - - let isInitialized = false; - let initializationAttempts = 0; - const maxAttempts = 3; - const timers = new Set(); - - const scheduleTimeout = (handler: () => void, delay: number) => { - /* eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout -- timeout is registered in the timers set and cleared during cleanup */ - const id = window.setTimeout(handler, delay); - timers.add(id); - return id; - }; - - const notifyBackend = async (action: string, stage?: string) => { - try { - if (stage) { - console.log(`[Layout] 通知后端 ${action}: ${stage}`); - await invoke("update_ui_stage", { stage }); - } else { - console.log(`[Layout] 通知后端 ${action}`); - await invoke("notify_ui_ready"); - } - } catch (err) { - console.error(`[Layout] 通知失败 ${action}:`, err); - } - }; - - const removeLoadingOverlay = () => { - const initialOverlay = document.getElementById("initial-loading-overlay"); - if (initialOverlay) { - console.log("[Layout] 移除加载指示器"); - initialOverlay.style.opacity = "0"; - scheduleTimeout(() => { - try { - initialOverlay.remove(); - } catch { - console.log("[Layout] 加载指示器已被移除"); - } - }, 300); - } - }; - - const performInitialization = async () => { - if (isInitialized) { - console.log("[Layout] 已经初始化过,跳过"); - return; - } - - initializationAttempts++; - console.log(`[Layout] 开始第 ${initializationAttempts} 次初始化尝试`); - - try { - removeLoadingOverlay(); - - await notifyBackend("加载阶段", "Loading"); - - await new Promise((resolve) => { - const checkReactMount = () => { - const rootElement = document.getElementById("root"); - if (rootElement && rootElement.children.length > 0) { - console.log("[Layout] React组件已挂载"); - resolve(); - } else { - scheduleTimeout(checkReactMount, 50); - } - }; - - checkReactMount(); - - scheduleTimeout(() => { - console.log("[Layout] React组件挂载检查超时,继续执行"); - resolve(); - }, 2000); - }); - - await notifyBackend("DOM就绪", "DomReady"); - - await new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }); - - await notifyBackend("资源加载完成", "ResourcesLoaded"); - - await notifyBackend("UI就绪"); - - isInitialized = true; - console.log(`[Layout] 第 ${initializationAttempts} 次初始化完成`); - } catch (error) { - console.error( - `[Layout] 第 ${initializationAttempts} 次初始化失败:`, - error, - ); - - if (initializationAttempts < maxAttempts) { - console.log( - `[Layout] 将在500ms后进行第 ${initializationAttempts + 1} 次重试`, - ); - scheduleTimeout(performInitialization, 500); - } else { - console.error("[Layout] 所有初始化尝试都失败,执行紧急初始化"); - - removeLoadingOverlay(); - try { - await notifyBackend("UI就绪"); - isInitialized = true; - } catch (e) { - console.error("[Layout] 紧急初始化也失败", e); - } - } - } - }; - - let hasEventTriggered = false; - - const setupEventListener = async () => { - try { - console.log("[Layout] 开始监听启动完成事件"); - // TODO: 监听启动完成事件的实现 - } catch (err) { - console.error("[Layout] 监听启动完成事件失败:", err); - } - }; - void setupEventListener(); - - const checkImmediateInitialization = async () => { - try { - console.log("[Layout] 检查后端是否已就绪"); - await invoke("update_ui_stage", { stage: "Loading" }); - - if (!hasEventTriggered && !isInitialized) { - console.log("[Layout] 后端已就绪,立即开始初始化"); - hasEventTriggered = true; - performInitialization(); - } - } catch { - console.log("[Layout] 后端尚未就绪,等待启动完成事件"); - } - }; - - const backupInitialization = scheduleTimeout(() => { - if (!hasEventTriggered && !isInitialized) { - console.warn("[Layout] 备用初始化触发:1.5秒内未开始初始化"); - hasEventTriggered = true; - performInitialization(); - } - }, 1500); - - const emergencyInitialization = scheduleTimeout(() => { - if (!isInitialized) { - console.error("[Layout] 紧急初始化触发,5秒内未完成初始化"); - removeLoadingOverlay(); - notifyBackend("UI就绪").catch(() => {}); - isInitialized = true; - } - }, 5000); - - const immediateInitTimer = scheduleTimeout( - checkImmediateInitialization, - 100, - ); - - return () => { - window.clearTimeout(backupInitialization); - window.clearTimeout(emergencyInitialization); - window.clearTimeout(immediateInitTimer); - timers.delete(backupInitialization); - timers.delete(emergencyInitialization); - timers.delete(immediateInitTimer); - timers.forEach((timeoutId) => window.clearTimeout(timeoutId)); - timers.clear(); - }; - }, []); - - // 语言和起始页设置 useEffect(() => { if (language) { dayjs.locale(language === "zh" ? "zh-cn" : language); diff --git a/src/pages/_layout/notificationHandlers.ts b/src/pages/_layout/notificationHandlers.ts new file mode 100644 index 000000000..0f69ce30a --- /dev/null +++ b/src/pages/_layout/notificationHandlers.ts @@ -0,0 +1,86 @@ +import { showNotice } from "@/services/noticeService"; + +type NavigateFunction = (path: string, options?: any) => void; +type TranslateFunction = (key: string) => string; + +export const handleNoticeMessage = ( + status: string, + msg: string, + t: TranslateFunction, + navigate: NavigateFunction, +) => { + const handlers: Record void> = { + "import_sub_url::ok": () => { + navigate("/profile", { state: { current: msg } }); + showNotice("success", t("Import Subscription Successful")); + }, + "import_sub_url::error": () => { + navigate("/profile"); + showNotice("error", msg); + }, + "set_config::error": () => showNotice("error", msg), + update_with_clash_proxy: () => + showNotice( + "success", + `${t("Update with Clash proxy successfully")} ${msg}`, + ), + update_retry_with_clash: () => + showNotice("info", t("Update failed, retrying with Clash proxy...")), + update_failed_even_with_clash: () => + showNotice( + "error", + `${t("Update failed even with Clash proxy")}: ${msg}`, + ), + update_failed: () => showNotice("error", msg), + "config_validate::boot_error": () => + showNotice("error", `${t("Boot Config Validation Failed")} ${msg}`), + "config_validate::core_change": () => + showNotice( + "error", + `${t("Core Change Config Validation Failed")} ${msg}`, + ), + "config_validate::error": () => + showNotice("error", `${t("Config Validation Failed")} ${msg}`), + "config_validate::process_terminated": () => + showNotice("error", t("Config Validation Process Terminated")), + "config_validate::stdout_error": () => + showNotice("error", `${t("Config Validation Failed")} ${msg}`), + "config_validate::script_error": () => + showNotice("error", `${t("Script File Error")} ${msg}`), + "config_validate::script_syntax_error": () => + showNotice("error", `${t("Script Syntax Error")} ${msg}`), + "config_validate::script_missing_main": () => + showNotice("error", `${t("Script Missing Main")} ${msg}`), + "config_validate::file_not_found": () => + showNotice("error", `${t("File Not Found")} ${msg}`), + "config_validate::yaml_syntax_error": () => + showNotice("error", `${t("YAML Syntax Error")} ${msg}`), + "config_validate::yaml_read_error": () => + showNotice("error", `${t("YAML Read Error")} ${msg}`), + "config_validate::yaml_mapping_error": () => + showNotice("error", `${t("YAML Mapping Error")} ${msg}`), + "config_validate::yaml_key_error": () => + showNotice("error", `${t("YAML Key Error")} ${msg}`), + "config_validate::yaml_error": () => + showNotice("error", `${t("YAML Error")} ${msg}`), + "config_validate::merge_syntax_error": () => + showNotice("error", `${t("Merge File Syntax Error")} ${msg}`), + "config_validate::merge_mapping_error": () => + showNotice("error", `${t("Merge File Mapping Error")} ${msg}`), + "config_validate::merge_key_error": () => + showNotice("error", `${t("Merge File Key Error")} ${msg}`), + "config_validate::merge_error": () => + showNotice("error", `${t("Merge File Error")} ${msg}`), + "config_core::change_success": () => + showNotice("success", `${t("Core Changed Successfully")}: ${msg}`), + "config_core::change_error": () => + showNotice("error", `${t("Failed to Change Core")}: ${msg}`), + }; + + const handler = handlers[status]; + if (handler) { + handler(); + } else { + console.warn(`未处理的通知状态: ${status}`); + } +}; diff --git a/src/pages/_layout/useAppInitialization.ts b/src/pages/_layout/useAppInitialization.ts new file mode 100644 index 000000000..34abf9f7e --- /dev/null +++ b/src/pages/_layout/useAppInitialization.ts @@ -0,0 +1,108 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useRef } from "react"; + +export const useAppInitialization = () => { + const initRef = useRef(false); + + useEffect(() => { + if (initRef.current) return; + initRef.current = true; + + let isInitialized = false; + let isCancelled = false; + const timers = new Set(); + + const scheduleTimeout = (handler: () => void, delay: number) => { + if (isCancelled) return -1; + const id = window.setTimeout(() => { + if (!isCancelled) { + handler(); + } + timers.delete(id); + }, delay); + timers.add(id); + return id; + }; + + const notifyBackend = async (stage?: string) => { + try { + if (stage) { + await invoke("update_ui_stage", { stage }); + } else { + await invoke("notify_ui_ready"); + } + } catch (err) { + console.error(`[初始化] 通知后端失败:`, err); + } + }; + + const removeLoadingOverlay = () => { + const overlay = document.getElementById("initial-loading-overlay"); + if (overlay) { + overlay.style.opacity = "0"; + scheduleTimeout(() => overlay.remove(), 300); + } + }; + + const performInitialization = async () => { + if (isInitialized) return; + isInitialized = true; + + try { + removeLoadingOverlay(); + await notifyBackend("Loading"); + + await new Promise((resolve) => { + const check = () => { + const root = document.getElementById("root"); + if (root && root.children.length > 0) { + resolve(); + } else { + scheduleTimeout(check, 50); + } + }; + check(); + scheduleTimeout(resolve, 2000); + }); + + await notifyBackend("DomReady"); + await new Promise((resolve) => requestAnimationFrame(resolve)); + await notifyBackend("ResourcesLoaded"); + await notifyBackend(); + } catch (error) { + console.error("[初始化] 失败:", error); + removeLoadingOverlay(); + notifyBackend().catch(console.error); + } + }; + + const checkBackendReady = async () => { + try { + await invoke("update_ui_stage", { stage: "Loading" }); + performInitialization(); + } catch { + scheduleTimeout(performInitialization, 1500); + } + }; + + scheduleTimeout(checkBackendReady, 100); + scheduleTimeout(() => { + if (!isInitialized) { + removeLoadingOverlay(); + notifyBackend().catch(console.error); + } + }, 5000); + + return () => { + isCancelled = true; + timers.forEach((id) => { + try { + window.clearTimeout(id); + } catch (error) { + console.warn("[初始化] 清理定时器失败:", error); + } + }); + timers.clear(); + }; + }, []); +}; diff --git a/src/pages/_layout/useLayoutEvents.ts b/src/pages/_layout/useLayoutEvents.ts new file mode 100644 index 000000000..c26084a3f --- /dev/null +++ b/src/pages/_layout/useLayoutEvents.ts @@ -0,0 +1,104 @@ +import { listen } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { useEffect } from "react"; +import { mutate } from "swr"; + +import { useListen } from "@/hooks/use-listen"; +import { getAxios } from "@/services/api"; + +export const useLayoutEvents = ( + handleNotice: (payload: [string, string]) => void, +) => { + const { addListener } = useListen(); + + useEffect(() => { + const unlisteners: Array<() => void> = []; + let disposed = false; + + const register = ( + maybeUnlisten: void | (() => void) | Promise void)>, + ) => { + if (!maybeUnlisten) return; + + if (typeof maybeUnlisten === "function") { + unlisteners.push(maybeUnlisten); + return; + } + + maybeUnlisten + .then((unlisten) => { + if (!unlisten) return; + if (disposed) { + unlisten(); + } else { + unlisteners.push(unlisten); + } + }) + .catch((error) => console.error("[事件监听] 注册失败", error)); + }; + + register( + addListener("verge://refresh-clash-config", async () => { + await getAxios(true); + mutate("getProxies"); + mutate("getVersion"); + mutate("getClashConfig"); + mutate("getProxyProviders"); + }), + ); + + register( + addListener("verge://refresh-verge-config", () => { + mutate("getVergeConfig"); + mutate("getSystemProxy"); + mutate("getAutotemProxy"); + mutate("getRunningMode"); + mutate("isServiceAvailable"); + }), + ); + + register( + addListener("verge://notice-message", ({ payload }) => + handleNotice(payload as [string, string]), + ), + ); + + const appWindow = getCurrentWebviewWindow(); + register( + (async () => { + const [hideUnlisten, showUnlisten] = await Promise.all([ + listen("verge://hide-window", () => appWindow.hide()), + listen("verge://show-window", () => appWindow.show()), + ]); + return () => { + hideUnlisten(); + showUnlisten(); + }; + })(), + ); + + return () => { + disposed = true; + const errors: Error[] = []; + + unlisteners.forEach((unlisten) => { + try { + unlisten(); + } catch (error) { + errors.push( + error instanceof Error ? error : new Error(String(error)), + ); + } + }); + + if (errors.length > 0) { + console.error( + `[事件监听] 清理过程中发生 ${errors.length} 个错误:`, + errors, + ); + } + + unlisteners.length = 0; + }; + }, [addListener, handleNotice]); +}; diff --git a/src/pages/_layout/useLazyDataLoad.ts b/src/pages/_layout/useLazyDataLoad.ts new file mode 100644 index 000000000..3eb0434cf --- /dev/null +++ b/src/pages/_layout/useLazyDataLoad.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from "react"; + +export const useLazyDataLoad = ( + callbacks: Array<() => void>, + delay: number = 1000, +) => { + const hasLoadedRef = useRef(false); + + useEffect(() => { + if (hasLoadedRef.current) return; + + const timer = window.setTimeout(() => { + hasLoadedRef.current = true; + callbacks.forEach((callback) => { + try { + callback(); + } catch (error) { + console.error("[延迟加载] 执行失败:", error); + } + }); + }, delay); + + return () => window.clearTimeout(timer); + }, [callbacks, delay]); +}; diff --git a/src/pages/_layout/useLoadingOverlay.ts b/src/pages/_layout/useLoadingOverlay.ts new file mode 100644 index 000000000..d26e138db --- /dev/null +++ b/src/pages/_layout/useLoadingOverlay.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef } from "react"; + +export const useLoadingOverlay = (themeReady: boolean) => { + const overlayRemovedRef = useRef(false); + + useEffect(() => { + if (!themeReady || overlayRemovedRef.current) return; + + let fadeTimer: number | null = null; + let retryTimer: number | null = null; + let attempts = 0; + const maxAttempts = 50; + let stopped = false; + + const tryRemoveOverlay = () => { + if (stopped || overlayRemovedRef.current) return; + + const overlay = document.getElementById("initial-loading-overlay"); + if (overlay) { + overlayRemovedRef.current = true; + overlay.style.opacity = "0"; + overlay.style.pointerEvents = "none"; + + fadeTimer = window.setTimeout(() => { + try { + overlay.remove(); + } catch (error) { + console.warn("[加载遮罩] 移除失败:", error); + } + }, 300); + return; + } + + if (attempts < maxAttempts) { + attempts += 1; + retryTimer = window.setTimeout(tryRemoveOverlay, 100); + } else { + console.warn("[加载遮罩] 未找到元素"); + } + }; + + tryRemoveOverlay(); + + return () => { + stopped = true; + if (fadeTimer) window.clearTimeout(fadeTimer); + if (retryTimer) window.clearTimeout(retryTimer); + }; + }, [themeReady]); +}; diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index 05341b157..2c17b849e 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -26,15 +26,15 @@ export const AppDataProvider = ({ }) => { const { verge } = useVerge(); - // 基础数据 - 中频率更新 (5秒) const { data: proxiesData, mutate: refreshProxy } = useSWR( "getProxies", calcuProxies, { - refreshInterval: 5000, - revalidateOnFocus: true, + refreshInterval: 8000, + revalidateOnFocus: false, suspense: false, - errorRetryCount: 3, + errorRetryCount: 2, + dedupingInterval: 3000, }, ); @@ -42,23 +42,23 @@ export const AppDataProvider = ({ "getClashConfig", getBaseConfig, { - refreshInterval: 60000, // 60秒刷新间隔,减少频繁请求 + refreshInterval: 60000, revalidateOnFocus: false, suspense: false, - errorRetryCount: 3, + errorRetryCount: 2, + dedupingInterval: 5000, }, ); - // 提供者数据 const { data: proxyProviders, mutate: refreshProxyProviders } = useSWR( "getProxyProviders", calcuProxyProviders, { revalidateOnFocus: false, revalidateOnReconnect: false, - dedupingInterval: 3000, + dedupingInterval: 5000, suspense: false, - errorRetryCount: 3, + errorRetryCount: 2, }, ); @@ -68,26 +68,26 @@ export const AppDataProvider = ({ { revalidateOnFocus: false, suspense: false, - errorRetryCount: 3, + errorRetryCount: 2, + dedupingInterval: 5000, }, ); - // 低频率更新数据 const { data: rulesData, mutate: refreshRules } = useSWR( "getRules", getRules, { revalidateOnFocus: false, suspense: false, - errorRetryCount: 3, + errorRetryCount: 2, + dedupingInterval: 5000, }, ); - // 监听profile和clash配置变更事件 useEffect(() => { let lastProfileId: string | null = null; let lastUpdateTime = 0; - const refreshThrottle = 500; + const refreshThrottle = 800; let isUnmounted = false; const scheduledTimeouts = new Set(); @@ -95,14 +95,17 @@ export const AppDataProvider = ({ const registerCleanup = (fn: () => void) => { if (isUnmounted) { - fn(); + try { + fn(); + } catch (error) { + console.error("[数据提供者] 立即清理失败:", error); + } } else { cleanupFns.push(fn); } }; const addWindowListener = (eventName: string, handler: EventListener) => { - // eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener -- cleanup is returned by this helper window.addEventListener(eventName, handler); return () => window.removeEventListener(eventName, handler); }; @@ -111,9 +114,13 @@ export const AppDataProvider = ({ callback: () => void | Promise, delay: number, ) => { + if (isUnmounted) return -1; + const timeoutId = window.setTimeout(() => { scheduledTimeouts.delete(timeoutId); - void callback(); + if (!isUnmounted) { + void callback(); + } }, delay); scheduledTimeouts.add(timeoutId); @@ -129,66 +136,48 @@ export const AppDataProvider = ({ const newProfileId = event.payload; const now = Date.now(); - console.log(`[AppDataProvider] Profile切换事件: ${newProfileId}`); - if ( lastProfileId === newProfileId && now - lastUpdateTime < refreshThrottle ) { - console.log("[AppDataProvider] 重复事件被防抖,跳过"); return; } lastProfileId = newProfileId; lastUpdateTime = now; - // 刷新规则数据 - refreshRules().catch((error) => - console.warn("[AppDataProvider] 规则刷新失败:", error), - ); - refreshRuleProviders().catch((error) => - console.warn("[AppDataProvider] 规则提供者刷新失败:", error), - ); + scheduleTimeout(() => { + refreshRules().catch((error) => + console.warn("[数据提供者] 规则刷新失败:", error), + ); + refreshRuleProviders().catch((error) => + console.warn("[数据提供者] 规则提供者刷新失败:", error), + ); + }, 200); }; const handleRefreshClash = () => { const now = Date.now(); - console.log("[AppDataProvider] Clash配置刷新事件"); - - if (now - lastUpdateTime <= refreshThrottle) { - return; - } + if (now - lastUpdateTime <= refreshThrottle) return; lastUpdateTime = now; - - scheduleTimeout(async () => { - try { - console.log("[AppDataProvider] Clash刷新 - 刷新代理数据"); - await refreshProxy(); - } catch (error) { - console.error( - "[AppDataProvider] Clash刷新时刷新代理数据失败:", - error, - ); - } - }, 0); + scheduleTimeout(() => { + refreshProxy().catch((error) => + console.error("[数据提供者] 代理刷新失败:", error), + ); + }, 200); }; const handleRefreshProxy = () => { const now = Date.now(); - console.log("[AppDataProvider] 代理配置刷新事件"); - - if (now - lastUpdateTime <= refreshThrottle) { - return; - } + if (now - lastUpdateTime <= refreshThrottle) return; lastUpdateTime = now; - scheduleTimeout(() => { refreshProxy().catch((error) => - console.warn("[AppDataProvider] 代理刷新失败:", error), + console.warn("[数据提供者] 代理刷新失败:", error), ); - }, 100); + }, 200); }; const initializeListeners = async () => { @@ -235,7 +224,24 @@ export const AppDataProvider = ({ return () => { isUnmounted = true; clearAllTimeouts(); - cleanupFns.splice(0).forEach((fn) => fn()); + + const errors: Error[] = []; + cleanupFns.splice(0).forEach((fn) => { + try { + fn(); + } catch (error) { + errors.push( + error instanceof Error ? error : new Error(String(error)), + ); + } + }); + + if (errors.length > 0) { + console.error( + `[数据提供者] 清理过程中发生 ${errors.length} 个错误:`, + errors, + ); + } }; }, [refreshProxy, refreshRules, refreshRuleProviders]); @@ -243,25 +249,26 @@ export const AppDataProvider = ({ "getSystemProxy", getSystemProxy, { - revalidateOnFocus: true, - revalidateOnReconnect: true, + revalidateOnFocus: false, + revalidateOnReconnect: false, suspense: false, - errorRetryCount: 3, + errorRetryCount: 2, + dedupingInterval: 5000, }, ); const { data: runningMode } = useSWR("getRunningMode", getRunningMode, { revalidateOnFocus: false, suspense: false, - errorRetryCount: 3, + errorRetryCount: 2, + dedupingInterval: 5000, }); - // 高频率更新数据 (2秒) const { data: uptimeData } = useSWR("appUptime", getAppUptime, { - // TODO: 运行时间 - refreshInterval: 2000, + refreshInterval: 3000, revalidateOnFocus: false, suspense: false, + errorRetryCount: 1, }); // 提供统一的刷新方法 diff --git a/src/providers/window/WindowProvider.tsx b/src/providers/window/WindowProvider.tsx index ec0d10ca0..b43d22daf 100644 --- a/src/providers/window/WindowProvider.tsx +++ b/src/providers/window/WindowProvider.tsx @@ -16,15 +16,22 @@ export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({ const minimize = useCallback(() => currentWindow.minimize(), [currentWindow]); useEffect(() => { + let isUnmounted = false; + const checkMaximized = debounce(async () => { - const value = await currentWindow.isMaximized(); - setMaximized(value); + if (!isUnmounted) { + const value = await currentWindow.isMaximized(); + setMaximized(value); + } }, 300); const unlistenPromise = currentWindow.onResized(checkMaximized); return () => { - unlistenPromise.then((unlisten) => unlisten()); + isUnmounted = true; + unlistenPromise + .then((unlisten) => unlisten()) + .catch((err) => console.warn("[WindowProvider] 清理监听器失败:", err)); }; }, [currentWindow]);