refactor: hooked up a backend-driven profile switch flow
This commit is contained in:
@@ -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的
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user