diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index 766cea73..e3ea4439 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -15,15 +15,36 @@ use crate::{ ret_err, utils::{dirs, help, logging::Type}, }; +use futures::FutureExt; +use once_cell::sync::OnceCell; use smartstring::alias::String; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::Duration; +use std::{ + panic::AssertUnwindSafe, + sync::atomic::{AtomicBool, AtomicU64, Ordering}, + time::Duration, +}; +use tokio::sync::Mutex; +static SWITCH_MUTEX: OnceCell> = OnceCell::new(); // 全局请求序列号跟踪,用于避免队列化执行 static CURRENT_REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); - static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false); +struct SwitchScope; + +impl SwitchScope { + fn begin() -> Self { + CURRENT_SWITCHING_PROFILE.store(true, Ordering::SeqCst); + Self + } +} + +impl Drop for SwitchScope { + fn drop(&mut self) { + CURRENT_SWITCHING_PROFILE.store(false, Ordering::SeqCst); + } +} + #[tauri::command] pub async fn get_profiles() -> CmdResult { // 策略1: 尝试快速获取latest数据 @@ -219,11 +240,17 @@ pub async fn delete_profile(index: String) -> CmdResult { /// 修改profiles的配置 #[tauri::command] pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult { + let mutex = SWITCH_MUTEX.get_or_init(|| Mutex::new(())); + let _guard = mutex.lock().await; + patch_profiles_config_internal(profiles).await +} + +async fn patch_profiles_config_internal(profiles: IProfiles) -> CmdResult { if CURRENT_SWITCHING_PROFILE.load(Ordering::SeqCst) { logging!(info, Type::Cmd, "当前正在切换配置,放弃请求"); return Ok(false); } - CURRENT_SWITCHING_PROFILE.store(true, Ordering::SeqCst); + let _switch_guard = SwitchScope::begin(); // 为当前请求分配序列号 let current_sequence = CURRENT_REQUEST_SEQUENCE.fetch_add(1, Ordering::SeqCst) + 1; @@ -561,13 +588,56 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult { /// 根据profile name修改profiles #[tauri::command] pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult { - logging!(info, Type::Cmd, "切换配置到: {}", profile_index); + switch_profile(profile_index, false).await +} - let profiles = IProfiles { - current: Some(profile_index), +#[tauri::command] +pub async fn switch_profile(profile_index: String, notify_success: bool) -> CmdResult { + logging!(info, Type::Cmd, "请求切换配置到: {}", &profile_index); + + let mutex = SWITCH_MUTEX.get_or_init(|| Mutex::new(())); + let _guard = mutex.lock().await; + + let switch_result = AssertUnwindSafe(patch_profiles_config_internal(IProfiles { + current: Some(profile_index.clone()), items: None, + })) + .catch_unwind() + .await; + + let result = match switch_result { + Ok(inner) => inner?, + Err(_) => { + logging!( + error, + Type::Cmd, + "切换配置过程中发生panic,目标: {}", + profile_index + ); + handle::Handle::notice_message( + "config_validate::panic", + format!("profile switch panic: {}", profile_index), + ); + handle::Handle::notify_profile_switch_finished(profile_index.clone(), false); + return Ok(false); + } }; - patch_profiles_config(profiles).await + + if result { + handle::Handle::notify_profile_switch_finished(profile_index.clone(), true); + } else { + handle::Handle::notify_profile_switch_finished(profile_index.clone(), false); + } + + if let Err(err) = handle::Handle::mihomo().await.close_all_connections().await { + logging!(warn, Type::Cmd, "切换配置后关闭连接失败: {}", err); + } + + if notify_success && result { + handle::Handle::notice_message("info", "Profile Switched"); + } + + Ok(result) } /// 修改某个profile item的 diff --git a/src-tauri/src/core/handle.rs b/src-tauri/src/core/handle.rs index 036aaec6..4d22c8a6 100644 --- a/src-tauri/src/core/handle.rs +++ b/src-tauri/src/core/handle.rs @@ -100,6 +100,17 @@ impl Handle { }); } + pub fn notify_profile_switch_finished(profile_id: String, success: bool) { + Self::send_event(FrontendEvent::ProfileSwitchFinished { + profile_id, + success, + }); + } + + pub fn notify_rust_panic(message: String, location: String) { + Self::send_event(FrontendEvent::RustPanic { message, location }); + } + pub fn notify_timer_updated(profile_index: String) { Self::send_event(FrontendEvent::TimerUpdated { profile_index }); } diff --git a/src-tauri/src/core/notification.rs b/src-tauri/src/core/notification.rs index 465eee03..aeef3592 100644 --- a/src-tauri/src/core/notification.rs +++ b/src-tauri/src/core/notification.rs @@ -23,9 +23,11 @@ pub enum FrontendEvent { ProxiesUpdated { payload: serde_json::Value }, NoticeMessage { status: String, message: String }, ProfileChanged { current_profile_id: String }, + ProfileSwitchFinished { profile_id: String, success: bool }, TimerUpdated { profile_index: String }, ProfileUpdateStarted { uid: String }, ProfileUpdateCompleted { uid: String }, + RustPanic { message: String, location: String }, } #[derive(Debug, Default)] @@ -167,6 +169,13 @@ impl NotificationSystem { FrontendEvent::ProfileChanged { current_profile_id } => { ("profile-changed", Ok(json!(current_profile_id))) } + FrontendEvent::ProfileSwitchFinished { + profile_id, + success, + } => ( + "profile-switch-finished", + Ok(json!({ "profileId": profile_id, "success": success })), + ), FrontendEvent::TimerUpdated { profile_index } => { ("verge://timer-updated", Ok(json!(profile_index))) } @@ -176,6 +185,10 @@ impl NotificationSystem { FrontendEvent::ProfileUpdateCompleted { uid } => { ("profile-update-completed", Ok(json!({ "uid": uid }))) } + FrontendEvent::RustPanic { message, location } => ( + "rust-panic", + Ok(json!({ "message": message, "location": location })), + ), } } @@ -201,10 +214,19 @@ impl NotificationSystem { } if let Some(sender) = &self.sender { - sender.send(event).is_ok() - } else { - false + if sender.send(event).is_err() { + logging!( + warn, + Type::Frontend, + "Failed to send event to worker thread" + ); + self.handle_emit_error(); + return false; + } + return true; } + + false } pub fn shutdown(&mut self) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5942ac15..062f62d3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -192,6 +192,7 @@ mod app_init { cmd::get_profiles, cmd::enhance_profiles, cmd::patch_profiles_config, + cmd::switch_profile, cmd::view_profile, cmd::patch_profile, cmd::create_profile, @@ -356,6 +357,28 @@ pub fn run() { } } + std::panic::set_hook(Box::new(|info| { + let payload = info + .payload() + .downcast_ref::<&'static str>() + .map(|s| (*s).to_string()) + .or_else(|| info.payload().downcast_ref::().cloned()) + .unwrap_or_else(|| "Unknown panic".to_string()); + let location = info + .location() + .map(|loc| format!("{}:{}", loc.file(), loc.line())) + .unwrap_or_else(|| "unknown location".to_string()); + + logging!( + error, + Type::System, + "Rust panic captured: {} @ {}", + payload, + location + ); + handle::Handle::notify_rust_panic(payload.into(), location.into()); + })); + #[cfg(feature = "clippy")] let context = tauri::test::mock_context(tauri::test::noop_assets()); #[cfg(feature = "clippy")] diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 77adf645..61d90fab 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -34,7 +34,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "react-router"; import useSWR, { mutate } from "swr"; -import { closeAllConnections } from "tauri-plugin-mihomo-api"; import { BasePage, DialogRef } from "@/components/base"; import { BaseStyledTextField } from "@/components/base/base-styled-text-field"; @@ -57,14 +56,9 @@ import { importProfile, reorderProfile, updateProfile, + switchProfileCommand, } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; -import { - enqueueSwitchTask, - subscribeSwitchWorker, - getSwitchWorkerSnapshot, - type SwitchWorkerEvent, -} from "@/services/profile-switch-worker"; import { useSetLoadingCache, useThemeMode } from "@/services/states"; // 记录profile切换状态 @@ -76,6 +70,16 @@ const debugProfileSwitch = (action: string, profile: string, extra?: any) => { ); }; +type ProfileSwitchFinishedPayload = { + profileId: string; + success: boolean; +}; + +type RustPanicPayload = { + message: string; + location: string; +}; + const normalizeProfileUrl = (value?: string) => { if (!value) return ""; const trimmed = value.trim(); @@ -208,7 +212,6 @@ const ProfilePage = () => { const { profiles = {}, activateSelected, - patchProfiles, mutateProfiles, error, isStale, @@ -379,34 +382,8 @@ const ProfilePage = () => { } }; - const closeConnections = useCallback(async () => { - await closeAllConnections(); - }, []); - const currentProfileId = profiles.current ?? null; - const [switchingProfileId, setSwitchingProfileId] = useState( - () => getSwitchWorkerSnapshot().switching, - ); - - useEffect(() => { - const unsubscribe = subscribeSwitchWorker((event: SwitchWorkerEvent) => { - if (event.type === "success" || event.type === "error") { - setActivatings((prev) => prev.filter((id) => id !== event.profile)); - } - setSwitchingProfileId(getSwitchWorkerSnapshot().switching); - }); - return unsubscribe; - }, [setActivatings]); - - useEffect(() => { - if (!switchingProfileId) return; - setActivatings((prev) => { - if (prev.includes(switchingProfileId)) return prev; - return [...prev, switchingProfileId]; - }); - }, [setActivatings, switchingProfileId]); - // 强化的刷新策略 const performRobustRefresh = async ( importVerifier: ImportLandingVerifier, @@ -485,57 +462,127 @@ const ProfilePage = () => { const onDragEnd = async (event: DragEndEvent) => { const { active, over } = event; - if (over) { - if (active.id !== over.id) { - await reorderProfile(active.id.toString(), over.id.toString()); - mutateProfiles(); - } + if (over && active.id !== over.id) { + await reorderProfile(active.id.toString(), over.id.toString()); + mutateProfiles(); } }; - const enqueueProfileSwitch = useCallback( - (targetProfile: string, notifySuccess: boolean) => { - const runSwitch = async () => { - await patchProfiles({ current: targetProfile }); + const [switchingProfileId, setSwitchingProfileId] = useState( + null, + ); + const pendingProfileRef = useRef(null); + const isSwitching = switchingProfileId != null; + + useEffect(() => { + let mounted = true; + const unlistenPromise = listen( + "profile-switch-finished", + (event) => { + if (!mounted) return; + const payload = event.payload; + if (!payload) return; + + setActivatings((prev) => prev.filter((id) => id !== payload.profileId)); + setSwitchingProfileId((prev) => + prev === payload.profileId ? null : prev, + ); + + if (!payload.success) { + showNotice("error", t("Profile switch failed")); + } + }, + ); + + return () => { + mounted = false; + unlistenPromise.then((unlisten) => unlisten()).catch(() => {}); + }; + }, [setActivatings, t]); + + const executeSwitch = useCallback( + async function run(targetProfile: string, notifySuccess: boolean) { + setSwitchingProfileId(targetProfile); + setActivatings((prev) => { + if (prev.includes(targetProfile)) return prev; + return [...prev, targetProfile]; + }); + + try { + const success = await switchProfileCommand( + targetProfile, + notifySuccess, + ); + if (!success) { + showNotice("error", t("Profile switch failed")); + return; + } await mutateLogs(); - await closeConnections(); - await new Promise((resolve) => setTimeout(resolve, 50)); await activateSelected(); if (notifySuccess) { showNotice("success", t("Profile Switched"), 1000); } - }; - - enqueueSwitchTask({ - profile: targetProfile, - notifySuccess, - run: runSwitch, - }); + } catch (error: any) { + showNotice( + "error", + error?.message || error?.toString?.() || String(error), + ); + } finally { + setActivatings((prev) => prev.filter((id) => id !== targetProfile)); + setSwitchingProfileId(null); + await mutateProfiles(); + const next = pendingProfileRef.current; + pendingProfileRef.current = null; + if (next && next !== targetProfile) { + await run(next, true); + } + } }, - [activateSelected, closeConnections, mutateLogs, patchProfiles, t], + [activateSelected, mutateLogs, mutateProfiles, setActivatings, t], ); const onSelect = useCallback( (targetProfile: string, force: boolean) => { - if (switchingProfileId === targetProfile) { - debugProfileSwitch("DUPLICATE_CLICK_IGNORED", targetProfile); + if (isSwitching) { + pendingProfileRef.current = targetProfile; return; } if (!force && targetProfile === currentProfileId) { debugProfileSwitch("ALREADY_CURRENT_IGNORED", targetProfile); return; } - enqueueProfileSwitch(targetProfile, true); + executeSwitch(targetProfile, true); }, - [enqueueProfileSwitch, currentProfileId, switchingProfileId], + [currentProfileId, executeSwitch, isSwitching], ); useEffect(() => { - if (current) { - mutateProfiles(); - enqueueProfileSwitch(current, false); + if (!current) return; + if (isSwitching) { + pendingProfileRef.current = current; + return; } - }, [current, enqueueProfileSwitch, mutateProfiles]); + if (current === currentProfileId) return; + executeSwitch(current, false); + }, [current, currentProfileId, executeSwitch, isSwitching]); + + useEffect(() => { + let mounted = true; + const panicListener = listen("rust-panic", (event) => { + if (!mounted) return; + const payload = event.payload; + if (!payload) return; + showNotice( + "error", + `Rust panic: ${payload.message} @ ${payload.location}`, + ); + console.error("Rust panic reported from backend:", payload); + }); + return () => { + mounted = false; + panicListener.then((unlisten) => unlisten()).catch(() => {}); + }; + }, [t]); const onEnhance = useLockFn(async (notifySuccess: boolean) => { if (switchingProfileId) { diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 49f767a1..f1b6b0b4 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -33,6 +33,13 @@ export async function patchProfilesConfig(profiles: IProfilesConfig) { return invoke("patch_profiles_config", { profiles }); } +export async function switchProfileCommand( + profileIndex: string, + notifySuccess: boolean, +) { + return invoke("switch_profile", { profileIndex, notifySuccess }); +} + export async function createProfile( item: Partial, fileData?: string | null, diff --git a/src/services/profile-switch-worker.ts b/src/services/profile-switch-worker.ts deleted file mode 100644 index e183f9ae..00000000 --- a/src/services/profile-switch-worker.ts +++ /dev/null @@ -1,87 +0,0 @@ -type SwitchTask = { - profile: string; - notifySuccess: boolean; - run: () => Promise; -}; - -export type SwitchWorkerEvent = - | { type: "start"; profile: string; notifySuccess: boolean } - | { type: "queued"; profile: string; notifySuccess: boolean } - | { type: "success"; profile: string; notifySuccess: boolean } - | { type: "error"; profile: string; error: string } - | { type: "idle" }; - -type Listener = (event: SwitchWorkerEvent) => void; - -const listeners = new Set(); - -let currentTask: SwitchTask | null = null; -let pendingTask: SwitchTask | null = null; -let running = false; - -const emit = (event: SwitchWorkerEvent) => { - listeners.forEach((listener) => { - try { - listener(event); - } catch (error) { - console.error("[ProfileSwitchWorker] Listener error:", error); - } - }); -}; - -const runCurrentTask = async () => { - if (!currentTask) { - emit({ type: "idle" }); - return; - } - - running = true; - const { profile, notifySuccess, run } = currentTask; - emit({ type: "start", profile, notifySuccess }); - - try { - await run(); - emit({ type: "success", profile, notifySuccess }); - } catch (error: any) { - const message = error?.message || String(error); - emit({ type: "error", profile, error: message }); - } finally { - running = false; - currentTask = null; - if (pendingTask) { - currentTask = pendingTask; - pendingTask = null; - queueMicrotask(runCurrentTask); - } else { - emit({ type: "idle" }); - } - } -}; - -export const enqueueSwitchTask = (task: SwitchTask) => { - if (running) { - pendingTask = task; - emit({ - type: "queued", - profile: task.profile, - notifySuccess: task.notifySuccess, - }); - return; - } - - currentTask = task; - runCurrentTask(); -}; - -export const subscribeSwitchWorker = (listener: Listener): (() => void) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -}; - -export const getSwitchWorkerSnapshot = () => ({ - switching: currentTask?.profile ?? null, - queued: pendingTask?.profile ?? null, - running, -});