🐛 fix(update): 修复 Windows 在线更新多实例确认流程

- 安装前获取维护锁并枚举同目录进程,仅检测到其他实例时触发关闭确认
- 改用 Windows 原生系统模态对话框并先收起下载进度窗口,避免确认提示被遮挡
- 单实例直接进入安装,取消确认或退出检查中止后恢复下载完成状态
- 多语言确认文案展示实际实例数量,补齐前后端与发布工作流回归测试
This commit is contained in:
Syngnat
2026-07-24 10:19:09 +08:00
parent 20c322627e
commit bbcafe63f4
17 changed files with 342 additions and 110 deletions

View File

@@ -2583,12 +2583,20 @@ function App() {
}
}, [t]);
const handleApplicationQuitRequest = useCallback(async (confirmedAction?: ApplicationQuitConfirmedAction) => {
const handleApplicationQuitRequest = useCallback(async (
confirmedAction?: ApplicationQuitConfirmedAction,
cancelledAction?: () => void,
) => {
if (applicationQuitHandlingRef.current) {
return;
}
applicationQuitHandlingRef.current = true;
const cancelRequest = () => {
resetApplicationQuitRequest();
cancelledAction?.();
};
const runConfirmedAction = async (): Promise<boolean> => {
let accepted = false;
try {
@@ -2601,14 +2609,14 @@ function App() {
accepted = true;
}
} catch (error) {
resetApplicationQuitRequest();
cancelRequest();
message.error(t('app.quit.message.quit_failed', {
detail: error instanceof Error ? error.message : String(error),
}));
return false;
}
if (!accepted) {
resetApplicationQuitRequest();
cancelRequest();
}
return accepted;
};
@@ -2622,7 +2630,7 @@ function App() {
latestState.savedQueries,
);
} catch (error) {
resetApplicationQuitRequest();
cancelRequest();
message.error(t('app.quit.unsaved_sql.inspect_failed', {
detail: error instanceof Error ? error.message : String(error),
}));
@@ -2663,14 +2671,14 @@ function App() {
</>
),
onCancel: () => {
resetApplicationQuitRequest();
cancelRequest();
},
onOk: async () => {
try {
await saveApplicationQuitUnsavedSQLTargets(targets, saveQuery);
message.success(t('app.quit.unsaved_sql.saved'));
} catch (error) {
resetApplicationQuitRequest();
cancelRequest();
message.error(t('app.quit.unsaved_sql.save_failed_cancel_exit', {
detail: error instanceof Error ? error.message : String(error),
}));
@@ -2684,24 +2692,12 @@ function App() {
}, [applicationQuitModalZIndex, ensureSavedQueriesLoaded, forceQuitApplication, resetApplicationQuitRequest, saveQuery, t]);
const handleInstallUpdateRequest = useCallback(async () => {
if (installMode === 'portable' || installMode === 'msi') {
Modal.confirm({
title: t('app.about.update_install_confirm.close_instances_title'),
content: t('app.about.update_install_confirm.close_instances_content'),
okText: t('app.about.update_install_confirm.close_instances_ok'),
cancelText: t('common.cancel'),
closable: true,
maskClosable: false,
zIndex: applicationQuitModalZIndex,
okButtonProps: { danger: true, type: 'primary' },
onOk: async () => {
await handleApplicationQuitRequest(() => handleInstallFromProgress(true));
},
});
return;
}
await handleApplicationQuitRequest(() => handleInstallFromProgress(false));
}, [applicationQuitModalZIndex, handleApplicationQuitRequest, handleInstallFromProgress, installMode, t]);
hideUpdateDownloadProgress();
await handleApplicationQuitRequest(
() => handleInstallFromProgress(false),
showUpdateDownloadProgress,
);
}, [handleApplicationQuitRequest, handleInstallFromProgress, hideUpdateDownloadProgress, showUpdateDownloadProgress]);
useEffect(() => {
const offBeforeClose = EventsOn('app:before-close-request', () => {

View File

@@ -10,15 +10,18 @@ const appSource = readFileSync(
describe('restart-to-update unsaved SQL guard', () => {
it('runs the confirmed update action through every application quit path', () => {
expect(appSource).toContain('type ApplicationQuitConfirmedAction = () => Promise<boolean>;');
expect(appSource).toContain('const handleApplicationQuitRequest = useCallback(async (confirmedAction?: ApplicationQuitConfirmedAction) => {');
expect(appSource).toContain('const handleApplicationQuitRequest = useCallback(async (');
expect(appSource).toContain('confirmedAction?: ApplicationQuitConfirmedAction,');
expect(appSource).toContain('cancelledAction?: () => void,');
expect(appSource).toContain('const runConfirmedAction = async (): Promise<boolean> => {');
const runnerStart = appSource.indexOf('const runConfirmedAction = async (): Promise<boolean> => {');
const runnerEnd = appSource.indexOf('\n let targets;', runnerStart);
const runnerSource = appSource.slice(runnerStart, runnerEnd);
expect(runnerSource).toContain('accepted = await confirmedAction();');
expect(runnerSource).toContain('await forceQuitApplication();\n accepted = true;');
expect(runnerSource).toContain('} catch (error) {\n resetApplicationQuitRequest();');
expect(runnerSource).toContain('if (!accepted) {\n resetApplicationQuitRequest();');
expect(runnerSource).toContain('} catch (error) {\n cancelRequest();');
expect(runnerSource).toContain('if (!accepted) {\n cancelRequest();');
expect(appSource).toContain('resetApplicationQuitRequest();\n cancelledAction?.();');
expect(runnerSource).toContain('return accepted;');
expect(appSource).toContain('if (targets.length === 0) {\n await runConfirmedAction();');
expect(appSource).toContain('void runConfirmedAction();');
@@ -57,15 +60,20 @@ describe('restart-to-update unsaved SQL guard', () => {
expect(loaderSource).not.toContain('await reloadSavedQueryGroups();');
});
it('confirms closing every Windows instance before entering the unsaved SQL guard', () => {
it('lets the backend confirm only actually running Windows instances after the unsaved SQL guard', () => {
expect(appSource).toContain('const handleInstallUpdateRequest = useCallback(async () => {');
expect(appSource).toContain("if (installMode === 'portable' || installMode === 'msi') {");
expect(appSource).toContain("title: t('app.about.update_install_confirm.close_instances_title')");
expect(appSource).toContain("content: t('app.about.update_install_confirm.close_instances_content')");
expect(appSource).toContain("okText: t('app.about.update_install_confirm.close_instances_ok')");
expect(appSource).toContain("cancelText: t('common.cancel')");
expect(appSource).toContain('await handleApplicationQuitRequest(() => handleInstallFromProgress(true));');
expect(appSource).toContain('await handleApplicationQuitRequest(() => handleInstallFromProgress(false));');
const installRequestStart = appSource.indexOf('const handleInstallUpdateRequest = useCallback(async () => {');
const installRequestEnd = appSource.indexOf('\n\n useEffect(() => {', installRequestStart);
const installRequestSource = appSource.slice(installRequestStart, installRequestEnd);
expect(installRequestSource.indexOf('hideUpdateDownloadProgress();')).toBeGreaterThan(-1);
expect(installRequestSource.indexOf('await handleApplicationQuitRequest(')).toBeGreaterThan(-1);
expect(installRequestSource.indexOf('hideUpdateDownloadProgress();')).toBeLessThan(
installRequestSource.indexOf('await handleApplicationQuitRequest('),
);
expect(installRequestSource).toContain('() => handleInstallFromProgress(false),');
expect(installRequestSource).toContain('showUpdateDownloadProgress,');
expect(appSource).not.toContain("title: t('app.about.update_install_confirm.close_instances_title')");
expect(appSource).not.toContain('handleInstallFromProgress(true)');
expect(appSource.match(/void handleInstallUpdateRequest\(\);/g)).toHaveLength(2);
expect(appSource).not.toContain('onClick={handleInstallFromProgress}');
expect(appSource).toContain("updateInstallAction === 'install-and-restart'");
@@ -73,7 +81,7 @@ describe('restart-to-update unsaved SQL guard', () => {
expect(appSource.match(/\{updateInstallActionLabel\}/g)).toHaveLength(2);
});
it('keeps every update quit confirmation above active settings and update dialogs', () => {
it('keeps the unsaved SQL quit confirmation above active settings and update dialogs', () => {
const unsavedConfirmStart = appSource.indexOf('const confirmRef = Modal.confirm({');
const installRequestStart = appSource.indexOf('const handleInstallUpdateRequest = useCallback', unsavedConfirmStart);
const installRequestEnd = appSource.indexOf('\n\n useEffect(() => {', installRequestStart);
@@ -87,6 +95,6 @@ describe('restart-to-update unsaved SQL guard', () => {
expect(appSource).toContain('const applicationQuitModalZIndex = Math.max(');
expect(appSource).toContain('settingsChildModalZIndex + 100,');
expect(unsavedConfirmSource).toContain('zIndex: applicationQuitModalZIndex');
expect(installRequestSource).toContain('zIndex: applicationQuitModalZIndex');
expect(installRequestSource).not.toContain('Modal.confirm({');
});
});

View File

@@ -568,6 +568,39 @@ describe('useAppUpdateManager', () => {
);
});
it('restores the ready state without an error toast when Windows instance confirmation is cancelled', async () => {
backendApp.CheckForUpdates.mockResolvedValue({
success: true,
data: {
hasUpdate: true,
currentVersion: '0.8.1',
latestVersion: '0.8.2',
downloaded: true,
assetSize: 2048,
installMode: 'portable',
packageType: 'portable',
},
});
backendApp.InstallUpdateAndRestart.mockResolvedValue({
success: false,
data: { cancelled: true },
});
renderHook();
await act(async () => {
await hook?.checkForUpdates(false);
});
let accepted = true;
await act(async () => {
accepted = await hook!.handleInstallFromProgress();
});
expect(accepted).toBe(false);
expect(hook?.updateDownloadProgress.status).toBe('done');
expect(messageApi.error).not.toHaveBeenCalled();
});
it('returns false without calling the backend when no update is ready', async () => {
renderHook();

View File

@@ -414,6 +414,16 @@ export const useAppUpdateManager = ({
res = { success: false, message: error?.message || t('common.unknown') };
}
if (!res?.success) {
if (res?.data?.cancelled === true) {
setUpdateDownloadProgress((prev) => ({
...prev,
open: true,
status: 'done',
percent: 100,
message: '',
}));
return false;
}
setUpdateDownloadProgress((prev) => ({
...prev,
open: true,