refactor: hooked up a backend-driven profile switch flow

This commit is contained in:
Slinetrac
2025-10-25 15:06:46 +08:00
Unverified
parent cc39dcdc09
commit 49926b4823
7 changed files with 252 additions and 159 deletions

View File

@@ -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<Mutex<()>> = 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<IProfiles> {
// 策略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<bool> {
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<bool> {
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<bool> {
/// 根据profile name修改profiles
#[tauri::command]
pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult<bool> {
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<bool> {
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的

View File

@@ -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 });
}

View File

@@ -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) {

View File

@@ -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::<String>().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")]

View File

@@ -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<string | null>(
() => 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<string | null>(
null,
);
const pendingProfileRef = useRef<string | null>(null);
const isSwitching = switchingProfileId != null;
useEffect(() => {
let mounted = true;
const unlistenPromise = listen<ProfileSwitchFinishedPayload>(
"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<RustPanicPayload>("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) {

View File

@@ -33,6 +33,13 @@ export async function patchProfilesConfig(profiles: IProfilesConfig) {
return invoke<void>("patch_profiles_config", { profiles });
}
export async function switchProfileCommand(
profileIndex: string,
notifySuccess: boolean,
) {
return invoke<boolean>("switch_profile", { profileIndex, notifySuccess });
}
export async function createProfile(
item: Partial<IProfileItem>,
fileData?: string | null,

View File

@@ -1,87 +0,0 @@
type SwitchTask = {
profile: string;
notifySuccess: boolean;
run: () => Promise<void>;
};
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<Listener>();
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,
});