diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs
index 5d840a9d..f8c6eb3a 100644
--- a/src-tauri/src/commands/system.rs
+++ b/src-tauri/src/commands/system.rs
@@ -875,7 +875,11 @@ pub fn migrate_legacy_autostart(project_name: Option<&str>) {
}
// 旧版计划任务 "MXU" 迁移到 "MXU-{项目名}",避免两个任务同时存在
if task_name != LEGACY_AUTOSTART_TASK_NAME {
- if let Some(legacy_xml) = query_schtask_xml(LEGACY_AUTOSTART_TASK_NAME) {
+ if let Some(legacy_xml) = query_schtask_xml(LEGACY_AUTOSTART_TASK_NAME).filter(|xml| {
+ std::env::current_exe()
+ .map(|exe| schtask_xml_targets_exe(xml, &exe))
+ .unwrap_or(false)
+ }) {
// 旧任务启用、而新任务不存在或已禁用时,以旧任务为准重建新任务;
// 旧任务被用户手动禁用时不新建,保留"已关闭"的状态
let target_enabled = query_schtask_xml(task_name)
@@ -981,6 +985,62 @@ fn xml_tag_equals(xml: &str, tag: &str, expected: &str) -> bool {
.unwrap_or(false)
}
+/// 只有 Command 指向当前可执行文件时,才允许迁移旧任务。
+#[cfg(any(windows, test))]
+fn schtask_xml_targets_exe(xml: &str, exe: &std::path::Path) -> bool {
+ let Some(command) = regex::Regex::new(r"(?is)\s*(.*?)\s*")
+ .ok()
+ .and_then(|re| re.captures(xml))
+ .and_then(|caps| caps.get(1))
+ else {
+ return false;
+ };
+ let command = command
+ .as_str()
+ .replace(""", "\"")
+ .replace("'", "'")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("&", "&");
+ let normalize = |path: &std::path::Path| {
+ path.canonicalize()
+ .unwrap_or_else(|_| path.to_path_buf())
+ .to_string_lossy()
+ .trim_start_matches(r"\\?\")
+ .replace('/', r"\")
+ .to_lowercase()
+ };
+ normalize(std::path::Path::new(command.trim().trim_matches('"'))) == normalize(exe)
+}
+
+#[cfg(test)]
+mod autostart_tests {
+ use super::schtask_xml_targets_exe;
+ use std::path::Path;
+
+ #[test]
+ fn legacy_task_must_target_the_current_installation() {
+ let exe = Path::new(r"C:\MXU-B\mxu.exe");
+ assert!(!schtask_xml_targets_exe(
+ r"C:\MXU-A\mxu.exe",
+ exe
+ ));
+ assert!(schtask_xml_targets_exe(
+ r"c:/mxu-b/MXU.EXE",
+ exe
+ ));
+ assert!(!schtask_xml_targets_exe("", exe));
+ }
+
+ #[test]
+ fn legacy_task_command_supports_xml_escaping_and_quoted_paths() {
+ assert!(schtask_xml_targets_exe(
+ r""C:\Maa & MXU\mxu.exe"",
+ Path::new(r"C:\Maa & MXU\mxu.exe")
+ ));
+ }
+}
+
/// 判断计划任务是否处于启用状态
///
/// 任务级启用状态记录在 `` 下的 ``,触发器里也有同名标签,
diff --git a/src/App.tsx b/src/App.tsx
index b5ab908f..631888a2 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -53,9 +53,9 @@ import {
consumeUpdateCompleteInfo,
savePendingUpdateInfo,
getPendingUpdateInfo,
- hasInstallFailedInSession,
clearPendingUpdateInfo,
isDebugVersion,
+ isExecutableInstaller,
} from '@/services/updateService';
import { initTelemetry, isTelemetryBlockedByBuild } from '@/services/telemetryService';
import { runAutoUpdateCheck } from '@/services/autoUpdateCheck';
@@ -433,41 +433,6 @@ function App() {
const initialized = useRef(false);
const downloadStartedRef = useRef(false);
const pendingAutoTasksRef = useRef(false);
- // 自动任务已分发但尚未完成启动时,也要阻止下载完成触发自动安装。
- // 否则小体积更新可能在 500ms 的任务分发延迟或连接准备期间先完成下载。
- const autoTaskLaunchPendingRef = useRef(false);
- // 尝试自动安装更新(无任务运行中时触发)
- const tryAutoInstallUpdate = useCallback(() => {
- const state = useAppStore.getState();
- if (state.downloadStatus !== 'completed') return;
- if (state.installStatus !== 'idle') return;
- if (state.autoInstallPending) return;
- if (autoTaskLaunchPendingRef.current) return;
- if (state.instances.some((i) => i.isRunning)) return;
- // 本次运行中安装失败过则不再自动重试,避免反复失败;手动安装和下次启动不受影响
- if (hasInstallFailedInSession()) {
- log.info('自动安装更新:本次运行中已安装失败过,跳过自动安装');
- return;
- }
-
- log.info('自动安装更新:条件满足,弹出安装');
- state.setAutoInstallPending(true);
- state.setShowInstallConfirmModal(true);
- }, []);
-
- // 监听任务结束 或 下载完成 后自动安装更新
- useEffect(() => {
- return useAppStore.subscribe((state, prev) => {
- const wasRunning = prev.instances.some((i) => i.isRunning);
- const nowRunning = state.instances.some((i) => i.isRunning);
- const downloadJustCompleted =
- prev.downloadStatus !== 'completed' && state.downloadStatus === 'completed';
- if ((wasRunning && !nowRunning) || downloadJustCompleted) {
- tryAutoInstallUpdate();
- }
- });
- }, [tryAutoInstallUpdate]);
-
// 自动下载函数
const startAutoDownload = useCallback(
async (updateResult: NonNullable>>) => {
@@ -520,9 +485,6 @@ function App() {
downloadSource: updateResult.downloadSource,
timestamp: Date.now(),
});
-
- // 尝试自动安装更新
- tryAutoInstallUpdate();
} else {
setDownloadStatus('failed');
// 下载失败时重置标志,允许后续重新下载(如填入 CDK 后切换下载源)
@@ -536,7 +498,7 @@ function App() {
downloadStartedRef.current = false;
}
},
- [setDownloadStatus, setDownloadProgress, setDownloadSavePath, tryAutoInstallUpdate],
+ [setDownloadStatus, setDownloadProgress, setDownloadSavePath],
);
// 启动检查和定时检查共用更新结果处理
@@ -1067,19 +1029,10 @@ function App() {
const dispatchPendingAutoStartTasks = () => {
if (!autoStartTasksPending) return;
autoStartTasksPending = false;
- autoTaskLaunchPendingRef.current = true;
setTimeout(() => {
document.dispatchEvent(
new CustomEvent('mxu-start-tasks', {
- detail: {
- source: 'autostart',
- onSettled: () => {
- autoTaskLaunchPendingRef.current = false;
- // 启动失败时任务不会产生 running -> idle 状态变化,需要在这里补一次安装检查;
- // 启动成功时仍处于运行态,本次检查会直接返回,并在任务结束后再次触发。
- tryAutoInstallUpdate();
- },
- },
+ detail: { source: 'autostart' },
}),
);
}, 500);
@@ -1114,6 +1067,7 @@ function App() {
normalizeVersion(currentVersionNow) === normalizeVersion(updateCompleteInfo.newVersion)
) {
log.info('版本已更新到目标版本');
+ clearPendingUpdateInfo();
if (isAutoStartModeNow) {
// 无人值守:跳过弹窗,继续检查是否有更新版本
log.info('自启动模式,跳过更新完成弹窗');
@@ -1160,8 +1114,20 @@ function App() {
downloadSource: pendingUpdate.downloadSource,
});
setDownloadSavePath(pendingUpdate.downloadSavePath);
- // 先设置 installStatus,再设置 downloadStatus,
- // 避免 subscribe 在 installStatus 还是 idle 时触发 tryAutoInstallUpdate 造成双发
+ // 前端刷新时后端任务可能仍在运行;隐藏自启动也不能等待交互式安装程序。
+ const backendStates = await maaService.getAllStates();
+ if (
+ !backendStates ||
+ Object.values(backendStates.instances).some((instance) => instance.isRunning) ||
+ useAppStore.getState().instances.some((instance) => instance.isRunning) ||
+ (isAutoStartModeNow && isExecutableInstaller(pendingUpdate.downloadSavePath))
+ ) {
+ log.info('本次启动跳过安装,保留更新包并继续任务');
+ setDownloadStatus('completed');
+ dispatchPendingAutoStartTasks();
+ return;
+ }
+ // 启动时安装已下载的更新,完成重启后再执行自动任务。
useAppStore.getState().setInstallStatus('installing');
setDownloadStatus('completed');
setShowInstallConfirmModal(true);
@@ -1572,12 +1538,8 @@ function App() {
// 回到主界面时,根据状态弹出相应的弹窗
useEffect(() => {
if (currentPage === 'main') {
- // 下载完成:弹出安装模态框
- if (downloadStatus === 'completed') {
- setShowInstallConfirmModal(true);
- }
- // 有更新、有错误或正在下载:弹出更新气泡
- else if (updateInfo?.hasUpdate || updateInfo?.errorCode || downloadStatus === 'downloading') {
+ // 更新状态通过气泡展示,已下载的更新在下次启动时自动安装。
+ if (updateInfo?.hasUpdate || updateInfo?.errorCode || downloadStatus === 'downloading') {
setShowUpdateDialog(true);
}
}
@@ -1587,16 +1549,8 @@ function App() {
updateInfo?.errorCode,
downloadStatus,
setShowUpdateDialog,
- setShowInstallConfirmModal,
]);
- // 下载完成时,强制弹出安装模态框
- useEffect(() => {
- if (downloadStatus === 'completed') {
- setShowInstallConfirmModal(true);
- }
- }, [downloadStatus, setShowInstallConfirmModal]);
-
// 下载失败或取消时,如果有挂起的自动任务,立即分发
useEffect(() => {
if (pendingAutoTasksRef.current && (downloadStatus === 'failed' || downloadStatus === 'idle')) {
diff --git a/src/components/InstallConfirmModal.tsx b/src/components/InstallConfirmModal.tsx
index fd965072..4507a2a9 100644
--- a/src/components/InstallConfirmModal.tsx
+++ b/src/components/InstallConfirmModal.tsx
@@ -53,8 +53,6 @@ export function InstallConfirmModal() {
setInstallError,
setJustUpdatedInfo,
resetInstallState,
- autoInstallPending,
- setAutoInstallPending,
} = useAppStore();
const currentVersion = projectInterface?.version || '';
@@ -226,23 +224,6 @@ export function InstallConfirmModal() {
t,
]);
- // 自动安装:由 tryAutoInstallUpdate 触发,通过 autoInstallPending 标记
- useEffect(() => {
- if (showInstallConfirmModal && autoInstallPending && installStatus === 'idle') {
- setAutoInstallPending(false);
- // 标记已触发,防止 handleInstall 设置 installStatus='installing' 后
- // 上方的 installStatus==='installing' effect 再次调用 installUpdate(竞态双发)
- autoInstallTriggered.current = true;
- handleInstall();
- }
- }, [
- showInstallConfirmModal,
- autoInstallPending,
- installStatus,
- setAutoInstallPending,
- handleInstall,
- ]);
-
// 判断当前是否为可执行安装程序(exe/dmg)
const isExeInstaller = downloadSavePath && isExecutableInstaller(downloadSavePath);
@@ -262,7 +243,8 @@ export function InstallConfirmModal() {
timestamp: Date.now(),
requireVersionCheck: true,
});
- clearPendingUpdateInfo();
+ // 打开安装程序不代表安装成功;取消或失败后仍需在下次启动恢复该更新。
+ // 待启动时确认版本已更新,再清除待安装记录。
// 不关闭弹窗、不重启
return;
}
@@ -455,15 +437,18 @@ export function InstallConfirmModal() {
)}
{/* 下载完成,可以安装 */}
- {!isJustUpdatedMode && isDownloadComplete && installStatus === 'idle' && (
-
- )}
+ {!isJustUpdatedMode &&
+ isDownloadComplete &&
+ downloadSavePath &&
+ installStatus === 'idle' && (
+
+ )}
{/* 正在安装 */}
{!isJustUpdatedMode && isInstalling && (
@@ -485,7 +470,7 @@ export function InstallConfirmModal() {
>
{t('common.close')}
- {!restartFailed && (
+ {!restartFailed && downloadSavePath && (
)}
+ {!restartFailed && !downloadSavePath && (
+
+ )}
>
)}
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index fbfcfe86..aea09085 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -1404,23 +1404,14 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr
useEffect(() => {
const handleStartTasks = async (evt: Event) => {
const detail = (evt as CustomEvent | undefined)?.detail as
- | { source?: string; combo?: string; onSettled?: (started: boolean) => void }
+ | { source?: string; combo?: string }
| undefined;
const isHotkey = detail?.source === 'hotkey' || detail?.source === 'global-hotkey';
- let settled = false;
- const notifySettled = (started: boolean) => {
- if (settled) return;
- settled = true;
- detail?.onSettled?.(started);
- };
-
if (hotkeyStartingRef.current) {
- notifySettled(false);
return;
}
const currentInstance = useAppStore.getState().getActiveInstance();
if (!currentInstance) {
- notifySettled(false);
return;
}
@@ -1447,10 +1438,8 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr
message: t('logs.messages.hotkeyStartSuccess'),
});
}
- notifySettled(success);
} finally {
hotkeyStartingRef.current = false;
- notifySettled(false);
}
};
diff --git a/src/components/UpdatePanel.tsx b/src/components/UpdatePanel.tsx
index e3e3bf32..74345fad 100644
--- a/src/components/UpdatePanel.tsx
+++ b/src/components/UpdatePanel.tsx
@@ -42,6 +42,7 @@ export function UpdatePanel({ onClose, anchorRef }: UpdatePanelProps) {
setDownloadProgress,
setDownloadSavePath,
resetDownloadState,
+ setInstallStatus,
setShowInstallConfirmModal,
} = useAppStore();
@@ -92,6 +93,11 @@ export function UpdatePanel({ onClose, anchorRef }: UpdatePanelProps) {
downloadSource: updateInfo.downloadSource,
timestamp: Date.now(),
});
+ // 此入口仅由用户点击重试触发,下载完成后直接安装。
+ if (!useAppStore.getState().instances.some((instance) => instance.isRunning)) {
+ setInstallStatus('installing');
+ setShowInstallConfirmModal(true);
+ }
} else {
setDownloadStatus('failed');
}
@@ -106,6 +112,8 @@ export function UpdatePanel({ onClose, anchorRef }: UpdatePanelProps) {
setDownloadStatus,
setDownloadProgress,
setDownloadSavePath,
+ setInstallStatus,
+ setShowInstallConfirmModal,
]);
// 自动下载已由 App.tsx 在检查更新后立即触发,此处不再重复处理
diff --git a/src/components/settings/UpdateSection.tsx b/src/components/settings/UpdateSection.tsx
index 6fbde039..b7169a37 100644
--- a/src/components/settings/UpdateSection.tsx
+++ b/src/components/settings/UpdateSection.tsx
@@ -22,6 +22,7 @@ import {
checkAndPrepareDownload,
openMirrorChyanWebsite,
downloadUpdate,
+ savePendingUpdateInfo,
getUpdateSavePath,
cancelDownload,
MIRRORCHYAN_ERROR_CODES,
@@ -123,7 +124,7 @@ export function UpdateSection() {
// 开始下载
const startDownload = useCallback(
- async (targetUpdateInfo?: typeof updateInfo) => {
+ async (targetUpdateInfo?: typeof updateInfo, installAfterDownload = false) => {
const info = targetUpdateInfo || updateInfo;
if (!info?.downloadUrl) return;
@@ -156,9 +157,26 @@ export function UpdateSection() {
});
if (result.success) {
- // 使用实际保存路径(可能与请求路径不同,如果从 302 重定向检测到正确文件名)
+ // 使用实际保存路径,并保留记录供下次启动安装。
+ savePendingUpdateInfo({
+ versionName: info.versionName,
+ releaseNote: info.releaseNote,
+ channel: info.channel,
+ downloadSavePath: result.actualSavePath,
+ fileSize: info.fileSize,
+ updateType: info.updateType,
+ downloadSource: info.downloadSource,
+ timestamp: Date.now(),
+ });
setDownloadSavePath(result.actualSavePath);
setDownloadStatus('completed');
+ if (
+ installAfterDownload &&
+ !useAppStore.getState().instances.some((instance) => instance.isRunning)
+ ) {
+ setInstallStatus('installing');
+ setShowInstallConfirmModal(true);
+ }
} else {
setDownloadStatus('failed');
}
@@ -173,6 +191,8 @@ export function UpdateSection() {
setDownloadStatus,
setDownloadProgress,
setDownloadSavePath,
+ setInstallStatus,
+ setShowInstallConfirmModal,
proxySettings,
mirrorChyanSettings.cdk,
],
@@ -350,7 +370,7 @@ export function UpdateSection() {
addDebugLog(
`下载来源: ${result.downloadSource === 'github' ? 'GitHub' : 'Mirror酱 CDN'}`,
);
- startDownload(result);
+ startDownload(result, true);
} else {
addDebugLog('无可用下载链接');
}
@@ -645,7 +665,7 @@ export function UpdateSection() {
onInstallClick={handleInstallNow}
onRetryClick={() => {
resetDownloadState();
- startDownload();
+ startDownload(undefined, true);
}}
progressBgClass="bg-bg-secondary"
onSlowDownloadHintClick={handleScrollToCdkInput}
diff --git a/src/services/updateService.ts b/src/services/updateService.ts
index e60ffb6f..aeb45d90 100644
--- a/src/services/updateService.ts
+++ b/src/services/updateService.ts
@@ -26,15 +26,6 @@ let isDownloading = false;
let downloadCancelled = false;
// 安装互斥锁,防止并发 installUpdate 导致目录竞争
let isInstalling = false;
-// 本次运行中是否有安装失败过:失败后不再自动重试,手动安装和下次启动不受影响
-let installFailedInSession = false;
-
-/**
- * 本次运行中是否有安装失败过(用于阻止自动安装反复重试)
- */
-export function hasInstallFailedInSession(): boolean {
- return installFailedInSession;
-}
/**
* 将文件移动到 cache/old 文件夹(调用 Rust 端统一实现)
@@ -838,8 +829,6 @@ export async function downloadUpdate(
currentSessionId = result.session_id;
log.info(`下载完成 (session ${result.session_id})`);
- // 新的更新包下载完成,允许再次自动安装
- installFailedInSession = false;
log.info(`实际保存路径: ${result.actual_save_path}`);
if (result.detected_filename) {
log.info(`检测到文件名: ${result.detected_filename}`);
@@ -1239,9 +1228,6 @@ export async function installUpdate(options: InstallUpdateOptions): Promise()(
setInstallStatus: (status) => set({ installStatus: status }),
setInstallError: (error) => set({ installError: error }),
setJustUpdatedInfo: (info) => set({ justUpdatedInfo: info }),
- autoInstallPending: false,
- setAutoInstallPending: (pending) => set({ autoInstallPending: pending }),
resetInstallState: () =>
set({
installStatus: 'idle',
installError: null,
- autoInstallPending: false,
}),
// 最近关闭的实例
diff --git a/src/stores/types.ts b/src/stores/types.ts
index 7e565201..a311d964 100644
--- a/src/stores/types.ts
+++ b/src/stores/types.ts
@@ -484,13 +484,10 @@ export interface AppState {
installStatus: InstallStatus;
installError: string | null;
justUpdatedInfo: JustUpdatedInfo | null;
- /** 自动安装待执行标记(由 tryAutoInstallUpdate 设置,InstallConfirmModal 消费) */
- autoInstallPending: boolean;
setShowInstallConfirmModal: (show: boolean) => void;
setInstallStatus: (status: InstallStatus) => void;
setInstallError: (error: string | null) => void;
setJustUpdatedInfo: (info: JustUpdatedInfo | null) => void;
- setAutoInstallPending: (pending: boolean) => void;
resetInstallState: () => void;
// 最近关闭的实例