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; // 最近关闭的实例