Compare commits

...

7 Commits

Author SHA1 Message Date
renovate[bot]
325de86e35
chore(deps): update github/gh-aw-actions action to v0.67.1 2026-04-06 09:23:45 +00:00
Tunglies
3aa39bff94
refactor: fix startup init chain — resolve_done semantics, dedupe events, cleanup
Backend:
- Move resolve_done() from sync setup() to async task after futures::join!
  so Timer waits for actual init completion instead of firing immediately
- Replace std:🧵:sleep(50ms) with tokio::time::sleep in async context
- Remove duplicate refresh_tray_menu in tray_init (keep post-join call only)
- Delete dead code reset_resolve_done (process restarts, static is destroyed)
- Rename create_window(is_show) → create_window(should_create) for clarity

Frontend:
- Remove duplicate verge://refresh-clash-config listener from AppDataProvider
  (useLayoutEvents handles it via invalidateQueries — single consumer path)
- Stabilize useEffect deps with useRef for TQ refetch references
- Simplify AppDataProvider event listener setup (profile-changed + proxy only)
2026-04-06 12:20:16 +08:00
Tunglies
437fef1c30
fix: eliminate error flash on startup by distinguishing loading from error state
- Change TQ_MIHOMO retryDelay from fixed 2000ms to exponential backoff
  (200ms → 400ms → 800ms, cap 3s) so core-dependent queries retry faster
- Expose isCoreDataPending from AppDataProvider to distinguish between
  data still loading vs actual errors
- ClashModeCard: show placeholder instead of "communication error" while
  core data is pending
- CurrentProxyCard: show empty space instead of "no active node" while
  core data is pending
2026-04-06 02:14:33 +08:00
Tunglies
ec82b69786
refactor: eliminate startup flicker — defer window show until overlay renders
- Remove Rust-side `eval(INITIAL_LOADING_OVERLAY)` that prematurely
  dismissed the overlay before React/MUI theme was ready
- Defer `window.show()` from Rust `activate_window` to an inline
  `<script>` in index.html, executed after the themed overlay is in DOM
- Remove `useAppInitialization` hook (duplicate of `useLoadingOverlay`
  with no themeReady gate)
- Simplify overlay to pure theme-colored background — no spinner or
  loading text — so fast startup feels instant
- Simplify `hideInitialOverlay` API and reduce overlay fade to 0.2s
- Clean up unused CSS variables (spinner-track, spinner-top, etc.)
2026-04-06 01:53:40 +08:00
Tunglies
04ce3d1772
refactor: remove unused UI notification functions and streamline initialization logic 2026-04-06 01:14:42 +08:00
Tunglies
b8fbabae04
fix: frontend memory leaks — Monaco dispose, TQ cache eviction, useEffect cleanup
- Dispose Monaco editor instances on dialog close to prevent cycle leak
- Replace gcTime: Infinity with finite TTLs and evict orphaned subscription queryKeys
- Add missing useEffect cleanup for timers, move setTimeout out of useMemo
2026-04-05 23:10:45 +08:00
renovate[bot]
2c766e1ada
chore(deps): update dependency @tauri-apps/plugin-updater to v2.10.1 (#6726)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-05 14:24:49 +00:00
31 changed files with 214 additions and 514 deletions

View File

@ -60,7 +60,7 @@ jobs:
title: ${{ steps.sanitized.outputs.title }} title: ${{ steps.sanitized.outputs.title }}
steps: steps:
- name: Setup Scripts - name: Setup Scripts
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7 uses: github/gh-aw-actions/setup@addd8a8bc8bad66050cec907c7bf182cca4d2e69 # v0.67.1
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
- name: Generate agentic run info - name: Generate agentic run info
@ -271,7 +271,7 @@ jobs:
output_types: ${{ steps.collect_output.outputs.output_types }} output_types: ${{ steps.collect_output.outputs.output_types }}
steps: steps:
- name: Setup Scripts - name: Setup Scripts
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7 uses: github/gh-aw-actions/setup@addd8a8bc8bad66050cec907c7bf182cca4d2e69 # v0.67.1
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
- name: Set runtime paths - name: Set runtime paths
@ -888,7 +888,7 @@ jobs:
total_count: ${{ steps.missing_tool.outputs.total_count }} total_count: ${{ steps.missing_tool.outputs.total_count }}
steps: steps:
- name: Setup Scripts - name: Setup Scripts
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7 uses: github/gh-aw-actions/setup@addd8a8bc8bad66050cec907c7bf182cca4d2e69 # v0.67.1
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
- name: Download agent output artifact - name: Download agent output artifact
@ -999,7 +999,7 @@ jobs:
process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
steps: steps:
- name: Setup Scripts - name: Setup Scripts
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7 uses: github/gh-aw-actions/setup@addd8a8bc8bad66050cec907c7bf182cca4d2e69 # v0.67.1
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
- name: Download agent output artifact - name: Download agent output artifact

View File

@ -54,7 +54,7 @@
"@tauri-apps/plugin-http": "~2.5.7", "@tauri-apps/plugin-http": "~2.5.7",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "2.3.5", "@tauri-apps/plugin-shell": "2.3.5",
"@tauri-apps/plugin-updater": "2.10.0", "@tauri-apps/plugin-updater": "2.10.1",
"ahooks": "^3.9.6", "ahooks": "^3.9.6",
"cidr-block": "^2.3.0", "cidr-block": "^2.3.0",
"dayjs": "1.11.20", "dayjs": "1.11.20",

10
pnpm-lock.yaml generated
View File

@ -69,8 +69,8 @@ importers:
specifier: 2.3.5 specifier: 2.3.5
version: 2.3.5 version: 2.3.5
'@tauri-apps/plugin-updater': '@tauri-apps/plugin-updater':
specifier: 2.10.0 specifier: 2.10.1
version: 2.10.0 version: 2.10.1
ahooks: ahooks:
specifier: ^3.9.6 specifier: ^3.9.6
version: 3.9.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 3.9.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -1562,8 +1562,8 @@ packages:
'@tauri-apps/plugin-shell@2.3.5': '@tauri-apps/plugin-shell@2.3.5':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
'@tauri-apps/plugin-updater@2.10.0': '@tauri-apps/plugin-updater@2.10.1':
resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==} resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@ -5059,7 +5059,7 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-updater@2.10.0': '@tauri-apps/plugin-updater@2.10.1':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1

View File

@ -1,8 +1,6 @@
use super::CmdResult; use super::CmdResult;
use crate::core::{autostart, handle}; use crate::core::autostart;
use crate::utils::resolve::ui::{self, UiReadyStage};
use crate::{cmd::StringifyErr as _, feat, utils::dirs}; use crate::{cmd::StringifyErr as _, feat, utils::dirs};
use clash_verge_logging::{Type, logging};
use smartstring::alias::String; use smartstring::alias::String;
use tauri::{AppHandle, Manager as _}; use tauri::{AppHandle, Manager as _};
@ -109,22 +107,3 @@ pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String>
pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult<String> { pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult<String> {
feat::copy_icon_file(path, icon_info).await feat::copy_icon_file(path, icon_info).await
} }
/// 通知UI已准备就绪
#[tauri::command]
pub async fn notify_ui_ready() {
logging!(info, Type::Cmd, "前端UI已准备就绪");
ui::mark_ui_ready();
handle::Handle::refresh_clash();
let delayed_refresh_delay = std::time::Duration::from_millis(1500);
tokio::time::sleep(delayed_refresh_delay).await;
handle::Handle::refresh_clash();
}
/// UI加载阶段
#[tauri::command]
pub fn update_ui_stage(stage: UiReadyStage) {
logging!(info, Type::Cmd, "UI加载阶段更新: {:?}", &stage);
ui::update_ui_ready_stage(stage);
}

View File

@ -3,7 +3,7 @@ use crate::{
core::{CoreManager, handle, tray}, core::{CoreManager, handle, tray},
feat::clean_async, feat::clean_async,
process::AsyncHandler, process::AsyncHandler,
utils::{self, resolve::reset_resolve_done}, utils,
}; };
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
@ -42,7 +42,6 @@ pub async fn restart_app() {
if cleanup_result { 0 } else { 1 } if cleanup_result { 0 } else { 1 }
); );
reset_resolve_done();
let app_handle = handle::Handle::app_handle(); let app_handle = handle::Handle::app_handle();
app_handle.restart(); app_handle.restart();
} }

View File

@ -149,8 +149,6 @@ mod app_init {
cmd::start_core, cmd::start_core,
cmd::stop_core, cmd::stop_core,
cmd::restart_core, cmd::restart_core,
cmd::notify_ui_ready,
cmd::update_ui_stage,
cmd::get_running_mode, cmd::get_running_mode,
cmd::get_auto_launch_status, cmd::get_auto_launch_status,
cmd::entry_lightweight_mode, cmd::entry_lightweight_mode,
@ -253,7 +251,6 @@ pub fn run() {
resolve::resolve_setup_async(); resolve::resolve_setup_async();
resolve::resolve_setup_sync(); resolve::resolve_setup_sync();
resolve::init_signal(); resolve::init_signal();
resolve::resolve_done();
logging!(info, Type::Setup, "初始化已启动"); logging!(info, Type::Setup, "初始化已启动");
Ok(()) Ok(())

View File

@ -6,6 +6,7 @@ use crate::{
config::Config, config::Config,
core::{ core::{
CoreManager, Timer, CoreManager, Timer,
handle::Handle,
hotkey::Hotkey, hotkey::Hotkey,
logger::Logger, logger::Logger,
service::{SERVICE_MANAGER, ServiceManager, is_service_ipc_path_exists}, service::{SERVICE_MANAGER, ServiceManager, is_service_ipc_path_exists},
@ -22,7 +23,6 @@ use clash_verge_signal;
pub mod dns; pub mod dns;
pub mod scheme; pub mod scheme;
pub mod ui;
pub mod window; pub mod window;
pub mod window_script; pub mod window_script;
@ -62,14 +62,9 @@ pub fn resolve_setup_async() {
init_system_proxy_guard().await; init_system_proxy_guard().await;
}); });
let tray_init = async {
init_tray().await;
refresh_tray_menu().await;
};
let _ = futures::join!( let _ = futures::join!(
core_init, core_init,
tray_init, init_tray(),
init_timer(), init_timer(),
init_hotkey(), init_hotkey(),
init_auto_lightweight_boot(), init_auto_lightweight_boot(),
@ -77,7 +72,9 @@ pub fn resolve_setup_async() {
init_silent_updater(), init_silent_updater(),
); );
Handle::refresh_clash();
refresh_tray_menu().await; refresh_tray_menu().await;
resolve_done();
}); });
} }
@ -219,7 +216,3 @@ pub fn resolve_done() {
pub fn is_resolve_done() -> bool { pub fn is_resolve_done() -> bool {
RESOLVE_DONE.load(Ordering::Acquire) RESOLVE_DONE.load(Ordering::Acquire)
} }
pub fn reset_resolve_done() {
RESOLVE_DONE.store(false, Ordering::Release);
}

View File

@ -1,57 +0,0 @@
use once_cell::sync::OnceCell;
use serde::{Deserialize, Serialize};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicU8, Ordering},
};
use tokio::sync::Notify;
use clash_verge_logging::{Type, logging};
// 获取 UI 是否准备就绪的全局状态
static UI_READY: AtomicBool = AtomicBool::new(false);
// 获取UI就绪状态细节
static UI_READY_STATE: AtomicU8 = AtomicU8::new(0);
// 添加通知机制,用于事件驱动的 UI 就绪检测
static UI_READY_NOTIFY: OnceCell<Arc<Notify>> = OnceCell::new();
// UI就绪阶段状态枚举
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum UiReadyStage {
NotStarted = 0,
Loading,
DomReady,
ResourcesLoaded,
Ready,
}
pub fn get_ui_ready() -> &'static AtomicBool {
&UI_READY
}
fn get_ui_ready_state() -> &'static AtomicU8 {
&UI_READY_STATE
}
fn get_ui_ready_notify() -> &'static Arc<Notify> {
UI_READY_NOTIFY.get_or_init(|| Arc::new(Notify::new()))
}
// 更新UI准备阶段
pub fn update_ui_ready_stage(stage: UiReadyStage) {
get_ui_ready_state().store(stage as u8, Ordering::Release);
// 如果是最终阶段标记UI完全就绪
if stage == UiReadyStage::Ready {
mark_ui_ready();
}
}
// 标记UI已准备就绪
pub fn mark_ui_ready() {
get_ui_ready().store(true, Ordering::Release);
logging!(info, Type::Window, "UI已标记为完全就绪");
// 通知所有等待的任务
get_ui_ready_notify().notify_waiters();
}

View File

@ -2,11 +2,7 @@ use dark_light::{Mode as SystemTheme, detect as detect_system_theme};
use tauri::utils::config::Color; use tauri::utils::config::Color;
use tauri::{Theme, WebviewWindow}; use tauri::{Theme, WebviewWindow};
use crate::{ use crate::{config::Config, core::handle, utils::resolve::window_script::build_window_initial_script};
config::Config,
core::handle,
utils::resolve::window_script::{INITIAL_LOADING_OVERLAY, build_window_initial_script},
};
use clash_verge_logging::{Type, logging_error}; use clash_verge_logging::{Type, logging_error};
const DARK_BACKGROUND_COLOR: Color = Color(46, 48, 61, 255); // #2E303D const DARK_BACKGROUND_COLOR: Color = Color(46, 48, 61, 255); // #2E303D
@ -82,7 +78,6 @@ pub async fn build_new_window() -> Result<WebviewWindow, String> {
match builder.build() { match builder.build() {
Ok(window) => { Ok(window) => {
logging_error!(Type::Window, window.set_background_color(Some(background_color))); logging_error!(Type::Window, window.set_background_color(Some(background_color)));
logging_error!(Type::Window, window.eval(INITIAL_LOADING_OVERLAY));
Ok(window) Ok(window)
} }
Err(e) => Err(e.to_string()), Err(e) => Err(e.to_string()),

View File

@ -91,11 +91,3 @@ pub const WINDOW_INITIAL_SCRIPT: &str = r##"
console.log('[Tauri] '); console.log('[Tauri] ');
"##; "##;
pub const INITIAL_LOADING_OVERLAY: &str = r"
const overlay = document.getElementById('initial-loading-overlay');
if (overlay) {
overlay.style.opacity = '0';
setTimeout(() => overlay.remove(), 300);
}
";

View File

@ -129,7 +129,7 @@ impl WindowManager {
logging!(info, Type::Window, "窗口不存在,创建新窗口"); logging!(info, Type::Window, "窗口不存在,创建新窗口");
if Self::create_window(true).await { if Self::create_window(true).await {
logging!(info, Type::Window, "窗口创建成功"); logging!(info, Type::Window, "窗口创建成功");
std::thread::sleep(std::time::Duration::from_millis(50)); tokio::time::sleep(std::time::Duration::from_millis(50)).await;
WindowOperationResult::Created WindowOperationResult::Created
} else { } else {
logging!(warn, Type::Window, "窗口创建失败"); logging!(warn, Type::Window, "窗口创建失败");
@ -285,31 +285,31 @@ impl WindowManager {
} }
/// 创建新窗口,防抖避免重复调用 /// 创建新窗口,防抖避免重复调用
pub fn create_window(is_show: bool) -> Pin<Box<dyn Future<Output = bool> + Send>> { /// 窗口创建后保持隐藏,由前端 index.html 在 overlay 渲染后调用 show避免主题闪烁
pub fn create_window(should_create: bool) -> Pin<Box<dyn Future<Output = bool> + Send>> {
Box::pin(async move { Box::pin(async move {
logging!(info, Type::Window, "开始创建/显示主窗口, is_show={}", is_show); logging!(info, Type::Window, "开始创建主窗口, should_create={}", should_create);
if !is_show { if !should_create {
return false; return false;
} }
let window = match build_new_window().await { match build_new_window().await {
Ok(window) => { Ok(_) => {
logging!(info, Type::Window, "新窗口创建成功"); logging!(info, Type::Window, "新窗口创建成功,等待前端渲染后显示");
window
}
Err(e) => {
logging!(error, Type::Window, "新窗口创建失败: {}", e);
return false;
}
};
// 直接激活刚创建的窗口,避免因防抖导致首次显示被跳过 #[cfg(target_os = "macos")]
if WindowOperationResult::Failed == Self::activate_window(&window) { {
return false; handle::Handle::global().set_activation_policy_regular();
} }
true true
}
Err(e) => {
logging!(error, Type::Window, "新窗口创建失败: {}", e);
false
}
}
}) })
} }

View File

@ -41,7 +41,7 @@ const MODE_META: Record<
export const ClashModeCard = () => { export const ClashModeCard = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { verge } = useVerge() const { verge } = useVerge()
const { clashConfig, refreshClashConfig } = useAppData() const { clashConfig, isCoreDataPending, refreshClashConfig } = useAppData()
// 支持的模式列表 // 支持的模式列表
const modeList = CLASH_MODES const modeList = CLASH_MODES
@ -57,8 +57,11 @@ export const ClashModeCard = () => {
if (currentModeKey) { if (currentModeKey) {
return t(MODE_META[currentModeKey].description) return t(MODE_META[currentModeKey].description)
} }
if (isCoreDataPending) {
return '\u00A0'
}
return t('home.components.clashMode.errors.communication') return t('home.components.clashMode.errors.communication')
}, [currentModeKey, t]) }, [currentModeKey, isCoreDataPending, t])
// 模式图标映射 // 模式图标映射
const modeIcons = useMemo( const modeIcons = useMemo(

View File

@ -105,7 +105,8 @@ export const CurrentProxyCard = () => {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const theme = useTheme() const theme = useTheme()
const { proxies, clashConfig, refreshProxy, rules } = useAppData() const { proxies, clashConfig, isCoreDataPending, refreshProxy, rules } =
useAppData()
const { verge } = useVerge() const { verge } = useVerge()
const { current: currentProfile } = useProfiles() const { current: currentProfile } = useProfiles()
const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false
@ -444,6 +445,12 @@ export const CurrentProxyCard = () => {
[setState], [setState],
) )
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [])
// 处理代理组变更 // 处理代理组变更
const handleGroupChange = useCallback( const handleGroupChange = useCallback(
(event: SelectChangeEvent<string>) => { (event: SelectChangeEvent<string>) => {
@ -905,7 +912,9 @@ export const CurrentProxyCard = () => {
</Box> </Box>
} }
> >
{currentProxy ? ( {isCoreDataPending ? (
<Box sx={{ py: 4 }} />
) : currentProxy ? (
<Box> <Box>
{/* 代理节点信息显示 */} {/* 代理节点信息显示 */}
<Box <Box

View File

@ -425,7 +425,7 @@ function useIPInfo() {
queryKey: [IP_INFO_CACHE_KEY], queryKey: [IP_INFO_CACHE_KEY],
queryFn: getIpInfo, queryFn: getIpInfo,
staleTime: Infinity, staleTime: Infinity,
gcTime: Infinity, gcTime: 60 * 60 * 1000,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
retry: 1, retry: 1,

View File

@ -168,6 +168,13 @@ export const EditorViewer = ({
} }
}, [open, syncMaximizedState]) }, [open, syncMaximizedState])
useEffect(() => {
return () => {
editorRef.current?.dispose()
editorRef.current = null
}
}, [])
return ( return (
<Dialog <Dialog
open={open} open={open}

View File

@ -34,11 +34,13 @@ import {
requestIdleCallback, requestIdleCallback,
} from 'foxact/request-idle-callback' } from 'foxact/request-idle-callback'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import { import {
startTransition, startTransition,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from 'react' } from 'react'
import { Controller, useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
@ -149,6 +151,7 @@ export const GroupsEditorViewer = (props: Props) => {
[t], [t],
) )
const themeMode = useThemeMode() const themeMode = useThemeMode()
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const [prevData, setPrevData] = useState('') const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState('') const [currData, setCurrData] = useState('')
const [visualization, setVisualization] = useState(true) const [visualization, setVisualization] = useState(true)
@ -481,6 +484,13 @@ export const GroupsEditorViewer = (props: Props) => {
getInterfaceNameList() getInterfaceNameList()
}, [fetchContent, fetchProfile, getInterfaceNameList, open]) }, [fetchContent, fetchProfile, getInterfaceNameList, open])
useEffect(() => {
return () => {
editorRef.current?.dispose()
editorRef.current = null
}
}, [])
const validateGroup = () => { const validateGroup = () => {
const group = formIns.getValues() const group = formIns.getValues()
if (group.name === '') { if (group.name === '') {
@ -1105,6 +1115,9 @@ export const GroupsEditorViewer = (props: Props) => {
language="yaml" language="yaml"
value={currData} value={currData}
theme={themeMode === 'light' ? 'light' : 'vs-dark'} theme={themeMode === 'light' ? 'light' : 'vs-dark'}
onMount={(editorInstance) => {
editorRef.current = editorInstance
}}
options={{ options={{
tabSize: 2, // 根据语言类型设置缩进大小 tabSize: 2, // 根据语言类型设置缩进大小
minimap: { minimap: {

View File

@ -19,7 +19,7 @@ import {
import { open } from '@tauri-apps/plugin-shell' import { open } from '@tauri-apps/plugin-shell'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useCallback, useEffect, useReducer, useState } from 'react' import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ConfirmViewer } from '@/components/profile/confirm-viewer' import { ConfirmViewer } from '@/components/profile/confirm-viewer'
@ -96,7 +96,11 @@ export const ProfileItem = (props: Props) => {
// 新增状态:是否显示下次更新时间 // 新增状态:是否显示下次更新时间
const [showNextUpdate, setShowNextUpdate] = useState(false) const [showNextUpdate, setShowNextUpdate] = useState(false)
const showNextUpdateRef = useRef(false)
const [nextUpdateTime, setNextUpdateTime] = useState('') const [nextUpdateTime, setNextUpdateTime] = useState('')
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
)
const { uid, name = 'Profile', extra, updated = 0, option } = itemData const { uid, name = 'Profile', extra, updated = 0, option } = itemData
@ -178,6 +182,10 @@ export const ProfileItem = (props: Props) => {
setShowNextUpdate(!showNextUpdate) setShowNextUpdate(!showNextUpdate)
} }
useEffect(() => {
showNextUpdateRef.current = showNextUpdate
}, [showNextUpdate])
// 当组件加载或更新间隔变化时更新下次更新时间 // 当组件加载或更新间隔变化时更新下次更新时间
useEffect(() => { useEffect(() => {
if (showNextUpdate) { if (showNextUpdate) {
@ -192,19 +200,18 @@ export const ProfileItem = (props: Props) => {
// 订阅定时器更新事件 // 订阅定时器更新事件
useEffect(() => { useEffect(() => {
let refreshTimeout: number | undefined
// 处理定时器更新事件 - 这个事件专门用于通知定时器变更 // 处理定时器更新事件 - 这个事件专门用于通知定时器变更
const handleTimerUpdate = (event: Event) => { const handleTimerUpdate = (event: Event) => {
const source = event as CustomEvent<string> & { payload?: string } const source = event as CustomEvent<string> & { payload?: string }
const updatedUid = source.detail ?? source.payload const updatedUid = source.detail ?? source.payload
// 只有当更新的是当前配置时才刷新显示 // 只有当更新的是当前配置时才刷新显示
if (updatedUid === itemData.uid && showNextUpdate) { if (updatedUid === itemData.uid && showNextUpdateRef.current) {
debugLog(`收到定时器更新事件: uid=${updatedUid}`) debugLog(`收到定时器更新事件: uid=${updatedUid}`)
if (refreshTimeout !== undefined) { if (refreshTimeoutRef.current !== undefined) {
clearTimeout(refreshTimeout) clearTimeout(refreshTimeoutRef.current)
} }
refreshTimeout = window.setTimeout(() => { refreshTimeoutRef.current = window.setTimeout(() => {
fetchNextUpdateTime(true) fetchNextUpdateTime(true)
}, 1000) }, 1000)
} }
@ -214,13 +221,13 @@ export const ProfileItem = (props: Props) => {
window.addEventListener('verge://timer-updated', handleTimerUpdate) window.addEventListener('verge://timer-updated', handleTimerUpdate)
return () => { return () => {
if (refreshTimeout !== undefined) { if (refreshTimeoutRef.current !== undefined) {
clearTimeout(refreshTimeout) clearTimeout(refreshTimeoutRef.current)
} }
// 清理事件监听 // 清理事件监听
window.removeEventListener('verge://timer-updated', handleTimerUpdate) window.removeEventListener('verge://timer-updated', handleTimerUpdate)
} }
}, [fetchNextUpdateTime, itemData.uid, showNextUpdate]) }, [fetchNextUpdateTime, itemData.uid])
// local file mode // local file mode
// remote file mode // remote file mode

View File

@ -27,11 +27,13 @@ import {
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import { import {
startTransition, startTransition,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -56,6 +58,7 @@ export const ProxiesEditorViewer = (props: Props) => {
const { profileUid, property, open, onClose, onSave } = props const { profileUid, property, open, onClose, onSave } = props
const { t } = useTranslation() const { t } = useTranslation()
const themeMode = useThemeMode() const themeMode = useThemeMode()
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const [prevData, setPrevData] = useState('') const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState('') const [currData, setCurrData] = useState('')
const [visualization, setVisualization] = useState(true) const [visualization, setVisualization] = useState(true)
@ -343,6 +346,13 @@ export const ProxiesEditorViewer = (props: Props) => {
fetchProfile() fetchProfile()
}, [fetchContent, fetchProfile, open]) }, [fetchContent, fetchProfile, open])
useEffect(() => {
return () => {
editorRef.current?.dispose()
editorRef.current = null
}
}, [])
const handleSave = useLockFn(async () => { const handleSave = useLockFn(async () => {
try { try {
await saveProfileFile(property, currData) await saveProfileFile(property, currData)
@ -469,6 +479,9 @@ export const ProxiesEditorViewer = (props: Props) => {
language="yaml" language="yaml"
value={currData} value={currData}
theme={themeMode === 'light' ? 'light' : 'vs-dark'} theme={themeMode === 'light' ? 'light' : 'vs-dark'}
onMount={(editorInstance) => {
editorRef.current = editorInstance
}}
options={{ options={{
tabSize: 2, // 根据语言类型设置缩进大小 tabSize: 2, // 根据语言类型设置缩进大小
minimap: { minimap: {

View File

@ -29,11 +29,13 @@ import {
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import { import {
startTransition, startTransition,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -251,6 +253,8 @@ export const RulesEditorViewer = (props: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const themeMode = useThemeMode() const themeMode = useThemeMode()
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const [prevData, setPrevData] = useState('') const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState('') const [currData, setCurrData] = useState('')
const [visualization, setVisualization] = useState(true) const [visualization, setVisualization] = useState(true)
@ -536,6 +540,13 @@ export const RulesEditorViewer = (props: Props) => {
fetchProfile() fetchProfile()
}, [fetchContent, fetchProfile, open]) }, [fetchContent, fetchProfile, open])
useEffect(() => {
return () => {
editorRef.current?.dispose()
editorRef.current = null
}
}, [])
const validateRule = () => { const validateRule = () => {
if ((ruleType.required ?? true) && !ruleContent) { if ((ruleType.required ?? true) && !ruleContent) {
throw new Error( throw new Error(
@ -770,6 +781,9 @@ export const RulesEditorViewer = (props: Props) => {
language="yaml" language="yaml"
value={currData} value={currData}
theme={themeMode === 'light' ? 'light' : 'vs-dark'} theme={themeMode === 'light' ? 'light' : 'vs-dark'}
onMount={(editorInstance) => {
editorRef.current = editorInstance
}}
options={{ options={{
tabSize: 2, // 根据语言类型设置缩进大小 tabSize: 2, // 根据语言类型设置缩进大小
minimap: { minimap: {

View File

@ -16,6 +16,7 @@ import {
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import type { Ref } from 'react' import type { Ref } from 'react'
import { import {
useCallback, useCallback,
@ -189,6 +190,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [visualization, setVisualization] = useState(true) const [visualization, setVisualization] = useState(true)
const skipYamlSyncRef = useRef(false) const skipYamlSyncRef = useRef(false)
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const [values, setValues] = useState<{ const [values, setValues] = useState<{
enable: boolean enable: boolean
listen: string listen: string
@ -453,6 +455,13 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
} }
}, [visualization]) }, [visualization])
useEffect(() => {
return () => {
editorRef.current?.dispose()
editorRef.current = null
}
}, [])
const initDnsConfig = useCallback(async () => { const initDnsConfig = useCallback(async () => {
try { try {
const dnsConfigExists = await invoke<boolean>( const dnsConfigExists = await invoke<boolean>(
@ -1057,6 +1066,9 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
value={yamlContent} value={yamlContent}
theme={themeMode === 'light' ? 'light' : 'vs-dark'} theme={themeMode === 'light' ? 'light' : 'vs-dark'}
className="flex-grow" className="flex-grow"
onMount={(editorInstance) => {
editorRef.current = editorInstance
}}
options={{ options={{
tabSize: 2, tabSize: 2,
minimap: { minimap: {

View File

@ -42,7 +42,7 @@ export const useIconCache = ({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
staleTime: Infinity, staleTime: Infinity,
gcTime: Infinity, gcTime: 30 * 60 * 1000,
retry: 2, retry: 2,
}) })

View File

@ -93,7 +93,7 @@ export const useMihomoWsSubscription = <T>(
subscriptionCacheKey ?? '$sub$__disabled__', subscriptionCacheKey ?? '$sub$__disabled__',
]) ?? fallbackData, ]) ?? fallbackData,
staleTime: Infinity, staleTime: Infinity,
gcTime: Infinity, gcTime: 30_000,
enabled: subscriptionCacheKey !== null, enabled: subscriptionCacheKey !== null,
}) })
@ -243,8 +243,11 @@ export const useMihomoWsSubscription = <T>(
}, [subscriptionCacheKey]) }, [subscriptionCacheKey])
const refresh = useCallback(() => { const refresh = useCallback(() => {
if (subscriptionCacheKey) {
queryClient.removeQueries({ queryKey: [subscriptionCacheKey] })
}
setDate(Date.now()) setDate(Date.now())
}, [setDate]) }, [queryClient, subscriptionCacheKey, setDate])
return { response, refresh, subscriptionCacheKey, wsRef } return { response, refresh, subscriptionCacheKey, wsRef }
} }

View File

@ -11,27 +11,15 @@
<title>Clash Verge</title> <title>Clash Verge</title>
<style> <style>
:root { :root {
--initial-bg: #f5f5f5; --bg-color: #f5f5f5;
--initial-text: #333; --text-color: #333;
--initial-spinner-track: #e3e3e3;
--initial-spinner-top: #3498db;
--bg-color: var(--initial-bg);
--text-color: var(--initial-text);
--spinner-track: var(--initial-spinner-track);
--spinner-top: var(--initial-spinner-top);
color-scheme: light; color-scheme: light;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--initial-bg: #2e303d; --bg-color: #2e303d;
--initial-text: #ffffff; --text-color: #ffffff;
--initial-spinner-track: #3a3a3a;
--initial-spinner-top: #0a84ff;
--bg-color: var(--initial-bg);
--text-color: var(--initial-text);
--spinner-track: var(--initial-spinner-track);
--spinner-top: var(--initial-spinner-top);
color-scheme: dark; color-scheme: dark;
} }
} }
@ -53,48 +41,28 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
background: var(--bg-color); background: var(--bg-color);
color: var(--text-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999; z-index: 9999;
font-family: transition: opacity 0.2s ease-out;
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transition: opacity 0.3s ease;
} }
#initial-loading-overlay[data-hidden='true'] { #initial-loading-overlay[data-hidden='true'] {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
.initial-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--spinner-track);
border-top: 3px solid var(--spinner-top);
border-radius: 50%;
animation: initial-spin 1s linear infinite;
}
@keyframes initial-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style> </style>
</head> </head>
<body> <body>
<div id="initial-loading-overlay"> <div id="initial-loading-overlay"></div>
<div class="initial-spinner"></div> <script>
<div style="font-size: 14px; opacity: 0.7; margin-top: 20px"> if (window.__TAURI_INTERNALS__) {
Loading Clash Verge... window.__TAURI_INTERNALS__
</div> .invoke('plugin:window|show', { label: 'main' })
</div> .catch(function () {});
window.__TAURI_INTERNALS__
.invoke('plugin:window|set_focus', { label: 'main' })
.catch(function () {});
}
</script>
<div id="root"></div> <div id="root"></div>
<script type="module" src="./main.tsx"></script> <script type="module" src="./main.tsx"></script>
</body> </body>

View File

@ -44,7 +44,6 @@ import { useThemeMode } from '@/services/states'
import getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
import { import {
useAppInitialization,
useCustomTheme, useCustomTheme,
useLayoutEvents, useLayoutEvents,
useLoadingOverlay, useLoadingOverlay,
@ -217,7 +216,6 @@ const Layout = () => {
) )
useLoadingOverlay(themeReady) useLoadingOverlay(themeReady)
useAppInitialization()
const handleNotice = useCallback( const handleNotice = useCallback(
(payload: [string, string]) => { (payload: [string, string]) => {

View File

@ -1,4 +1,3 @@
export { useAppInitialization } from './use-app-initialization'
export { useLayoutEvents } from './use-layout-events' export { useLayoutEvents } from './use-layout-events'
export { useLoadingOverlay } from './use-loading-overlay' export { useLoadingOverlay } from './use-loading-overlay'
export { useNavMenuOrder } from './use-nav-menu-order' export { useNavMenuOrder } from './use-nav-menu-order'

View File

@ -1,112 +0,0 @@
import { invoke } from '@tauri-apps/api/core'
import { useEffect, useRef } from 'react'
import { hideInitialOverlay } from '../utils'
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<number>()
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) => {
if (isCancelled) return
try {
if (stage) {
await invoke('update_ui_stage', { stage })
} else {
await invoke('notify_ui_ready')
}
} catch (err) {
console.error(`[Initialization] Failed to notify backend:`, err)
}
}
const removeLoadingOverlay = () => {
hideInitialOverlay({ schedule: scheduleTimeout })
}
const performInitialization = async () => {
if (isCancelled || isInitialized) return
isInitialized = true
try {
removeLoadingOverlay()
await notifyBackend('Loading')
await new Promise<void>((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) {
if (!isCancelled) {
console.error('[Initialization] Failed:', error)
removeLoadingOverlay()
notifyBackend().catch(console.error)
}
}
}
const checkBackendReady = async () => {
try {
if (isCancelled) return
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('[Initialization] Failed to clear timer:', error)
}
})
timers.clear()
}
}, [])
}

View File

@ -309,20 +309,22 @@ export const useCustomTheme = () => {
styleElement.innerHTML = effectiveInjectedCss + globalStyles styleElement.innerHTML = effectiveInjectedCss + globalStyles
} }
const { palette } = muiTheme
setTimeout(() => {
const dom = document.querySelector('#Gradient2')
if (dom) {
dom.innerHTML = `
<stop offset="0%" stop-color="${palette.primary.main}" />
<stop offset="80%" stop-color="${palette.primary.dark}" />
<stop offset="100%" stop-color="${palette.primary.dark}" />
`
}
}, 0)
return muiTheme return muiTheme
}, [mode, theme_setting, userBackgroundImage, hasUserBackground]) }, [mode, theme_setting, userBackgroundImage, hasUserBackground])
useEffect(() => {
const id = setTimeout(() => {
const dom = document.querySelector('#Gradient2')
if (dom) {
dom.innerHTML = `
<stop offset="0%" stop-color="${theme.palette.primary.main}" />
<stop offset="80%" stop-color="${theme.palette.primary.dark}" />
<stop offset="100%" stop-color="${theme.palette.primary.dark}" />
`
}
}, 0)
return () => clearTimeout(id)
}, [theme.palette.primary.main, theme.palette.primary.dark])
return { theme } return { theme }
} }

View File

@ -3,46 +3,15 @@ import { useEffect, useRef } from 'react'
import { hideInitialOverlay } from '../utils' import { hideInitialOverlay } from '../utils'
export const useLoadingOverlay = (themeReady: boolean) => { export const useLoadingOverlay = (themeReady: boolean) => {
const overlayRemovedRef = useRef(false) const doneRef = useRef(false)
useEffect(() => { useEffect(() => {
if (!themeReady || overlayRemovedRef.current) return if (!themeReady || doneRef.current) return
doneRef.current = true
let removalTimer: number | undefined
let retryTimer: number | undefined
let attempts = 0
const maxAttempts = 50
let stopped = false
const tryRemoveOverlay = () => {
if (stopped || overlayRemovedRef.current) return
const { removed, removalTimer: timerId } = hideInitialOverlay({
assumeMissingAsRemoved: true,
})
if (typeof timerId === 'number') {
removalTimer = timerId
}
if (removed) {
overlayRemovedRef.current = true
return
}
if (attempts < maxAttempts) {
attempts += 1
retryTimer = window.setTimeout(tryRemoveOverlay, 100)
} else {
console.warn('[Loading Overlay] Element not found')
}
}
tryRemoveOverlay()
const timer = hideInitialOverlay()
return () => { return () => {
stopped = true if (timer !== undefined) window.clearTimeout(timer)
if (typeof removalTimer === 'number') window.clearTimeout(removalTimer)
if (typeof retryTimer === 'number') window.clearTimeout(retryTimer)
} }
}, [themeReady]) }, [themeReady])
} }

View File

@ -1,45 +1,17 @@
const OVERLAY_ID = 'initial-loading-overlay' let removed = false
const REMOVE_DELAY = 300
let overlayRemoved = false export const hideInitialOverlay = (): number | undefined => {
if (removed) return undefined
type HideOverlayOptions = { const overlay = document.getElementById('initial-loading-overlay')
schedule?: (handler: () => void, delay: number) => number
assumeMissingAsRemoved?: boolean
}
type HideOverlayResult = {
removed: boolean
removalTimer?: number
}
export const hideInitialOverlay = (
options: HideOverlayOptions = {},
): HideOverlayResult => {
if (overlayRemoved) {
return { removed: true }
}
const overlay = document.getElementById(OVERLAY_ID)
if (!overlay) { if (!overlay) {
if (options.assumeMissingAsRemoved) { removed = true
overlayRemoved = true return undefined
return { removed: true }
}
return { removed: false }
} }
overlayRemoved = true removed = true
overlay.dataset.hidden = 'true' overlay.dataset.hidden = 'true'
const schedule = options.schedule ?? window.setTimeout const timer = window.setTimeout(() => overlay.remove(), 200)
const removalTimer = schedule(() => { return timer
try {
overlay.remove()
} catch (error) {
console.warn('[Loading Overlay] Removal failed:', error)
}
}, REMOVE_DELAY)
return { removed: true, removalTimer }
} }

View File

@ -16,6 +16,7 @@ export interface AppDataContextType {
proxyProviders: Record<string, ProxyProvider> proxyProviders: Record<string, ProxyProvider>
ruleProviders: Record<string, RuleProvider> ruleProviders: Record<string, RuleProvider>
systemProxyAddress: string systemProxyAddress: string
isCoreDataPending: boolean
refreshProxy: () => Promise<any> refreshProxy: () => Promise<any>
refreshClashConfig: () => Promise<any> refreshClashConfig: () => Promise<any>

View File

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import React, { useCallback, useEffect, useMemo } from 'react' import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { import {
getBaseConfig, getBaseConfig,
getRuleProviders, getRuleProviders,
@ -23,7 +23,7 @@ const TQ_MIHOMO = {
refetchOnReconnect: false, refetchOnReconnect: false,
staleTime: 1500, staleTime: 1500,
retry: 3, retry: 3,
retryDelay: 2000, retryDelay: (attempt: number) => Math.min(200 * 2 ** attempt, 3000),
} as const } as const
const TQ_DEFAULTS = { const TQ_DEFAULTS = {
@ -41,13 +41,21 @@ export const AppDataProvider = ({
}) => { }) => {
const { verge } = useVerge() const { verge } = useVerge()
const { data: proxiesData, refetch: refreshProxy } = useQuery({ const {
data: proxiesData,
isPending: isProxiesPending,
refetch: refreshProxy,
} = useQuery({
queryKey: ['getProxies'], queryKey: ['getProxies'],
queryFn: calcuProxies, queryFn: calcuProxies,
...TQ_MIHOMO, ...TQ_MIHOMO,
}) })
const { data: clashConfig, refetch: refreshClashConfig } = useQuery({ const {
data: clashConfig,
isPending: isClashConfigPending,
refetch: refreshClashConfig,
} = useQuery({
queryKey: ['getClashConfig'], queryKey: ['getClashConfig'],
queryFn: getBaseConfig, queryFn: getBaseConfig,
...TQ_MIHOMO, ...TQ_MIHOMO,
@ -71,106 +79,45 @@ export const AppDataProvider = ({
...TQ_MIHOMO, ...TQ_MIHOMO,
}) })
const refreshProxyRef = useRef(refreshProxy)
const refreshRulesRef = useRef(refreshRules)
const refreshRuleProvidersRef = useRef(refreshRuleProviders)
useEffect(() => {
refreshProxyRef.current = refreshProxy
}, [refreshProxy])
useEffect(() => {
refreshRulesRef.current = refreshRules
}, [refreshRules])
useEffect(() => {
refreshRuleProvidersRef.current = refreshRuleProviders
}, [refreshRuleProviders])
useEffect(() => { useEffect(() => {
let lastProfileId: string | null = null let lastProfileId: string | null = null
let lastUpdateTime = 0 let lastUpdateTime = 0
const refreshThrottle = 800 const refreshThrottle = 800
let isUnmounted = false
const scheduledTimeouts = new Set<number>()
const cleanupFns: Array<() => void> = [] const cleanupFns: Array<() => void> = []
const registerCleanup = (fn: () => void) => {
if (isUnmounted) {
try {
fn()
} catch (error) {
console.error('[DataProvider] Immediate cleanup failed:', error)
}
} else {
cleanupFns.push(fn)
}
}
const addWindowListener = (eventName: string, handler: EventListener) => {
// eslint-disable-next-line @eslint-react/web-api-no-leaked-event-listener
window.addEventListener(eventName, handler)
return () => window.removeEventListener(eventName, handler)
}
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 handleProfileChanged = (event: { payload: string }) => {
const newProfileId = event.payload const newProfileId = event.payload
const now = Date.now() const now = Date.now()
if ( if (
lastProfileId === newProfileId && lastProfileId === newProfileId &&
now - lastUpdateTime < refreshThrottle now - lastUpdateTime < refreshThrottle
) { ) {
return return
} }
lastProfileId = newProfileId lastProfileId = newProfileId
lastUpdateTime = now lastUpdateTime = now
refreshRulesRef.current().catch(() => {})
scheduleTimeout(() => { refreshRuleProvidersRef.current().catch(() => {})
refreshRules().catch((error) =>
console.warn('[DataProvider] Rules refresh failed:', error),
)
refreshRuleProviders().catch((error) =>
console.warn('[DataProvider] Rule providers refresh failed:', error),
)
}, 200)
}
const handleRefreshClash = () => {
const now = Date.now()
if (now - lastUpdateTime <= refreshThrottle) return
lastUpdateTime = now
scheduleTimeout(async () => {
await Promise.all([
refreshProxy().catch((error) =>
console.error('[DataProvider] Proxy refresh failed:', error),
),
refreshClashConfig().catch((error) =>
console.error('[DataProvider] Clash config refresh failed:', error),
),
])
}, 200)
} }
const handleRefreshProxy = () => { const handleRefreshProxy = () => {
const now = Date.now() const now = Date.now()
if (now - lastUpdateTime <= refreshThrottle) return if (now - lastUpdateTime <= refreshThrottle) return
lastUpdateTime = now lastUpdateTime = now
scheduleTimeout(() => { refreshProxyRef.current().catch(() => {})
refreshProxy().catch((error) =>
console.warn('[DataProvider] Proxy refresh failed:', error),
)
}, 200)
} }
const initializeListeners = async () => { const initializeListeners = async () => {
@ -179,62 +126,34 @@ export const AppDataProvider = ({
'profile-changed', 'profile-changed',
handleProfileChanged, handleProfileChanged,
) )
registerCleanup(unlistenProfile) cleanupFns.push(unlistenProfile)
} catch (error) { } catch (error) {
console.error('[AppDataProvider] 监听 Profile 事件失败:', error) console.error('[AppDataProvider] 监听 Profile 事件失败:', error)
} }
try { try {
const unlistenClash = await listen(
'verge://refresh-clash-config',
handleRefreshClash,
)
const unlistenProxy = await listen( const unlistenProxy = await listen(
'verge://refresh-proxy-config', 'verge://refresh-proxy-config',
handleRefreshProxy, handleRefreshProxy,
) )
cleanupFns.push(unlistenProxy)
registerCleanup(() => {
unlistenClash()
unlistenProxy()
})
} catch (error) { } catch (error) {
console.warn('[AppDataProvider] 设置 Tauri 事件监听器失败:', 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))
})
} }
} }
void initializeListeners() void initializeListeners()
return () => { return () => {
isUnmounted = true cleanupFns.forEach((fn) => {
clearAllTimeouts()
const errors: Error[] = []
cleanupFns.splice(0).forEach((fn) => {
try { try {
fn() fn()
} catch (error) { } catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error))) console.error('[DataProvider] Cleanup error:', error)
} }
}) })
if (errors.length > 0) {
console.error(
`[DataProvider] ${errors.length} errors during cleanup:`,
errors,
)
} }
} }, [])
}, [refreshProxy, refreshClashConfig, refreshRules, refreshRuleProviders])
const { data: sysproxy, refetch: refreshSysproxy } = useQuery({ const { data: sysproxy, refetch: refreshSysproxy } = useQuery({
queryKey: ['getSystemProxy'], queryKey: ['getSystemProxy'],
@ -323,6 +242,9 @@ export const AppDataProvider = ({
systemProxyAddress: calculateSystemProxyAddress(), systemProxyAddress: calculateSystemProxyAddress(),
// core 数据加载状态
isCoreDataPending: isProxiesPending || isClashConfigPending,
// 刷新方法 // 刷新方法
refreshProxy, refreshProxy,
refreshClashConfig, refreshClashConfig,
@ -335,6 +257,8 @@ export const AppDataProvider = ({
}, [ }, [
proxiesData, proxiesData,
clashConfig, clashConfig,
isProxiesPending,
isClashConfigPending,
rulesData, rulesData,
sysproxy, sysproxy,
runningMode, runningMode,