Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)<Command>\s*(.*?)\s*</Command>")
.ok()
.and_then(|re| re.captures(xml))
.and_then(|caps| caps.get(1))
else {
return false;
};
let command = command
.as_str()
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&");
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"<Exec><Command>C:\MXU-A\mxu.exe</Command></Exec>",
exe
));
assert!(schtask_xml_targets_exe(
r"<Exec><Command>c:/mxu-b/MXU.EXE</Command></Exec>",
exe
));
assert!(!schtask_xml_targets_exe("<Exec></Exec>", exe));
}

#[test]
fn legacy_task_command_supports_xml_escaping_and_quoted_paths() {
assert!(schtask_xml_targets_exe(
r"<Exec><Command>&quot;C:\Maa &amp; MXU\mxu.exe&quot;</Command></Exec>",
Path::new(r"C:\Maa & MXU\mxu.exe")
));
}
}

/// 判断计划任务是否处于启用状态
///
/// 任务级启用状态记录在 `<Settings>` 下的 `<Enabled>`,触发器里也有同名标签,
Expand Down
86 changes: 20 additions & 66 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Awaited<ReturnType<typeof checkAndPrepareDownload>>>) => {
Expand Down Expand Up @@ -520,9 +485,6 @@ function App() {
downloadSource: updateResult.downloadSource,
timestamp: Date.now(),
});

// 尝试自动安装更新
tryAutoInstallUpdate();
} else {
setDownloadStatus('failed');
// 下载失败时重置标志,允许后续重新下载(如填入 CDK 后切换下载源)
Expand All @@ -536,7 +498,7 @@ function App() {
downloadStartedRef.current = false;
}
},
[setDownloadStatus, setDownloadProgress, setDownloadSavePath, tryAutoInstallUpdate],
[setDownloadStatus, setDownloadProgress, setDownloadSavePath],
);

// 启动检查和定时检查共用更新结果处理
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1114,6 +1067,7 @@ function App() {
normalizeVersion(currentVersionNow) === normalizeVersion(updateCompleteInfo.newVersion)
) {
log.info('版本已更新到目标版本');
clearPendingUpdateInfo();
if (isAutoStartModeNow) {
// 无人值守:跳过弹窗,继续检查是否有更新版本
log.info('自启动模式,跳过更新完成弹窗');
Expand Down Expand Up @@ -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;
}
// 启动时安装已下载的更新,完成重启后再执行自动任务。
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
useAppStore.getState().setInstallStatus('installing');
setDownloadStatus('completed');
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
setShowInstallConfirmModal(true);
Expand Down Expand Up @@ -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);
Comment on lines +1541 to 1543

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist settings downloads before deferring their installation

When an update is downloaded through desktop Settings—via a manual check, retry, or CDK/source switch—UpdateSection.startDownload only updates the in-memory path and status (src/components/settings/UpdateSection.tsx:158–161); unlike the App and UpdatePanel paths, it never calls savePendingUpdateInfo. With completion-triggered installation removed, the next launch gets null from getPendingUpdateInfo and skips the already-downloaded package, so this workflow no longer installs automatically and can download the same update again. Persist successful Settings downloads too, or centralize persistence, before relying exclusively on startup installation.

AGENTS.md reference: AGENTS.md:L45-L45

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4892363: The Settings download flow now saves completed update metadata with savePendingUpdateInfo, allowing startup to find and resume that package.

}
}
Expand All @@ -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')) {
Expand Down
57 changes: 27 additions & 30 deletions src/components/InstallConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,6 @@ export function InstallConfirmModal() {
setInstallError,
setJustUpdatedInfo,
resetInstallState,
autoInstallPending,
setAutoInstallPending,
} = useAppStore();

const currentVersion = projectInterface?.version || '';
Expand Down Expand Up @@ -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);

Expand All @@ -262,7 +243,8 @@ export function InstallConfirmModal() {
timestamp: Date.now(),
requireVersionCheck: true,
});
clearPendingUpdateInfo();
// 打开安装程序不代表安装成功;取消或失败后仍需在下次启动恢复该更新。
// 待启动时确认版本已更新,再清除待安装记录。
// 不关闭弹窗、不重启
return;
}
Expand Down Expand Up @@ -455,15 +437,18 @@ export function InstallConfirmModal() {
)}

{/* 下载完成,可以安装 */}
{!isJustUpdatedMode && isDownloadComplete && installStatus === 'idle' && (
<button
onClick={handleInstall}
className="flex items-center gap-2 px-4 py-2 text-sm bg-accent text-white hover:bg-accent-hover rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
{t('mirrorChyan.installNow')}
</button>
)}
{!isJustUpdatedMode &&
isDownloadComplete &&
downloadSavePath &&
installStatus === 'idle' && (
<button
onClick={handleInstall}
className="flex items-center gap-2 px-4 py-2 text-sm bg-accent text-white hover:bg-accent-hover rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
{t('mirrorChyan.installNow')}
</button>
)}

{/* 正在安装 */}
{!isJustUpdatedMode && isInstalling && (
Expand All @@ -485,7 +470,7 @@ export function InstallConfirmModal() {
>
{t('common.close')}
</button>
{!restartFailed && (
{!restartFailed && downloadSavePath && (
<button
onClick={() => {
resetInstallState();
Expand All @@ -497,6 +482,18 @@ export function InstallConfirmModal() {
{t('mirrorChyan.retry')}
</button>
)}
{!restartFailed && !downloadSavePath && (
<button
onClick={() => {
handleClose();
openUpdateSettings();
}}
className="flex items-center gap-2 px-4 py-2 text-sm bg-accent text-white hover:bg-accent-hover rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4" />
{t('mirrorChyan.checkUpdate')}
</button>
)}
</>
)}
</div>
Expand Down
13 changes: 1 addition & 12 deletions src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -1447,10 +1438,8 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr
message: t('logs.messages.hotkeyStartSuccess'),
});
}
notifySettled(success);
} finally {
hotkeyStartingRef.current = false;
notifySettled(false);
}
};

Expand Down
8 changes: 8 additions & 0 deletions src/components/UpdatePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function UpdatePanel({ onClose, anchorRef }: UpdatePanelProps) {
setDownloadProgress,
setDownloadSavePath,
resetDownloadState,
setInstallStatus,
setShowInstallConfirmModal,
} = useAppStore();

Expand Down Expand Up @@ -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');
}
Expand All @@ -106,6 +112,8 @@ export function UpdatePanel({ onClose, anchorRef }: UpdatePanelProps) {
setDownloadStatus,
setDownloadProgress,
setDownloadSavePath,
setInstallStatus,
setShowInstallConfirmModal,
]);

// 自动下载已由 App.tsx 在检查更新后立即触发,此处不再重复处理
Expand Down
Loading
Loading