From fd6b5ca14528a30e8cb31d6274bbc8c6845ea856 Mon Sep 17 00:00:00 2001 From: MistEO <18511905+MistEO@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:57:06 +0800 Subject: [PATCH] fix(update): bind pending packages to their original project and version --- src/App.tsx | 23 +- src/components/UpdatePanel.tsx | 16 +- src/components/settings/UpdateSection.tsx | 4 +- src/services/updateService.ts | 40 +++- src/stores/types.ts | 2 + tests/pendingUpdate.test.ts | 256 ++++++++++++++++++++++ 6 files changed, 308 insertions(+), 33 deletions(-) create mode 100644 tests/pendingUpdate.test.ts diff --git a/src/App.tsx b/src/App.tsx index 6b8c4545..5d2e0018 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,7 +51,7 @@ import { downloadUpdate, getUpdateSavePath, consumeUpdateCompleteInfo, - savePendingUpdateInfo, + completeUpdateDownload, getPendingUpdateInfo, hasInstallFailedInSession, clearPendingUpdateInfo, @@ -525,22 +525,9 @@ function App() { if (result.success) { // 使用实际保存路径(可能与请求路径不同,如果从 302 重定向检测到正确文件名) - setDownloadSavePath(result.actualSavePath); - setDownloadStatus('completed'); + completeUpdateDownload(updateResult, result.actualSavePath); log.info('更新下载完成'); - // 保存待安装更新信息,以便下次启动时自动安装 - savePendingUpdateInfo({ - versionName: updateResult.versionName, - releaseNote: updateResult.releaseNote, - channel: updateResult.channel, - downloadSavePath: result.actualSavePath, - fileSize: updateResult.fileSize, - updateType: updateResult.updateType, - downloadSource: updateResult.downloadSource, - timestamp: Date.now(), - }); - // 尝试自动安装更新 tryAutoInstallUpdate(); } else { @@ -1165,12 +1152,16 @@ function App() { // 检查是否有待安装的更新(上次下载完成但未安装) // 调试版本跳过待安装更新检测 if (!isDebugVersion(result.interface.version)) { - const pendingUpdate = await getPendingUpdateInfo(result.interface.version); + const pendingUpdate = await getPendingUpdateInfo( + result.interface.version, + result.interface.mirrorchyan_rid, + ); if (pendingUpdate) { log.info('检测到待安装更新:', pendingUpdate.versionName); // 恢复更新状态 setUpdateInfo({ hasUpdate: true, + origin: pendingUpdate.origin, versionName: pendingUpdate.versionName, releaseNote: pendingUpdate.releaseNote, channel: pendingUpdate.channel, diff --git a/src/components/UpdatePanel.tsx b/src/components/UpdatePanel.tsx index e3e3bf32..e502cf0d 100644 --- a/src/components/UpdatePanel.tsx +++ b/src/components/UpdatePanel.tsx @@ -15,7 +15,7 @@ import { downloadUpdate, getUpdateSavePath, MIRRORCHYAN_ERROR_CODES, - savePendingUpdateInfo, + completeUpdateDownload, } from '@/services/updateService'; import { proxySettingsForUpdateDownload } from '@/services/proxyService'; import { DownloadProgressBar, useOpenUpdateSettings } from './UpdateInfoCard'; @@ -79,19 +79,7 @@ export function UpdatePanel({ onClose, anchorRef }: UpdatePanelProps) { if (result.success) { // 使用实际保存路径(可能与请求路径不同,如果从 302 重定向检测到正确文件名) - setDownloadSavePath(result.actualSavePath); - setDownloadStatus('completed'); - // 保存待安装更新信息,以便下次启动时自动安装 - savePendingUpdateInfo({ - versionName: updateInfo.versionName, - releaseNote: updateInfo.releaseNote, - channel: updateInfo.channel, - downloadSavePath: result.actualSavePath, - fileSize: updateInfo.fileSize, - updateType: updateInfo.updateType, - downloadSource: updateInfo.downloadSource, - timestamp: Date.now(), - }); + completeUpdateDownload(updateInfo, result.actualSavePath); } else { setDownloadStatus('failed'); } diff --git a/src/components/settings/UpdateSection.tsx b/src/components/settings/UpdateSection.tsx index 6fbde039..95c742c7 100644 --- a/src/components/settings/UpdateSection.tsx +++ b/src/components/settings/UpdateSection.tsx @@ -22,6 +22,7 @@ import { checkAndPrepareDownload, openMirrorChyanWebsite, downloadUpdate, + completeUpdateDownload, getUpdateSavePath, cancelDownload, MIRRORCHYAN_ERROR_CODES, @@ -157,8 +158,7 @@ export function UpdateSection() { if (result.success) { // 使用实际保存路径(可能与请求路径不同,如果从 302 重定向检测到正确文件名) - setDownloadSavePath(result.actualSavePath); - setDownloadStatus('completed'); + completeUpdateDownload(info, result.actualSavePath); } else { setDownloadStatus('failed'); } diff --git a/src/services/updateService.ts b/src/services/updateService.ts index d858ef43..f9f5e63a 100644 --- a/src/services/updateService.ts +++ b/src/services/updateService.ts @@ -895,6 +895,12 @@ export async function checkAndPrepareDownload( // 始终使用 Mirror酱 检查更新 const updateInfo = await checkUpdate({ ...checkOptions, cdk, channel }); + if (updateInfo) { + updateInfo.origin = { + resourceId: checkOptions.resourceId, + baseVersion: checkOptions.currentVersion, + }; + } if (!updateInfo || !updateInfo.hasUpdate) { return updateInfo; @@ -1292,6 +1298,7 @@ export interface UpdateCompleteInfo { * 待安装的更新信息(下载完成后保存,用于下次启动时自动安装) */ export interface PendingUpdateInfo { + origin?: UpdateInfo['origin']; versionName: string; releaseNote: string; channel?: string; @@ -1361,12 +1368,31 @@ export function savePendingUpdateInfo(info: PendingUpdateInfo): void { } } +/** 保存下载来源和实际包路径后,再发布可安装状态。 */ +export function completeUpdateDownload(info: UpdateInfo, downloadSavePath: string): void { + savePendingUpdateInfo({ + origin: info.origin, + versionName: info.versionName, + releaseNote: info.releaseNote, + channel: info.channel, + downloadSavePath, + fileSize: info.fileSize, + updateType: info.updateType, + downloadSource: info.downloadSource, + timestamp: Date.now(), + }); + const state = useAppStore.getState(); + state.setDownloadSavePath(downloadSavePath); + state.setDownloadStatus('completed'); +} + /** * 读取待安装更新信息;仍有效的记录由安装流程清除 - * 如果更新包文件丢失,或待安装版本不高于当前安装版本,则自动清除记录 + * 来源或基线不匹配、更新包丢失、目标版本过期时,清除记录后重新检查更新。 */ export async function getPendingUpdateInfo( installedVersion: string | undefined, + resourceId: string | undefined, ): Promise { try { const data = localStorage.getItem(PENDING_UPDATE_STORAGE_KEY); @@ -1374,6 +1400,18 @@ export async function getPendingUpdateInfo( const info = JSON.parse(data) as PendingUpdateInfo; + // 历史记录没有来源时也重新检查,避免猜测旧增量包适用的安装基线。 + if ( + !resourceId || + !installedVersion || + info.origin?.resourceId !== resourceId || + info.origin.baseVersion !== installedVersion + ) { + log.info('待安装更新来源或基线不匹配,清除记录并重新检查更新'); + localStorage.removeItem(PENDING_UPDATE_STORAGE_KEY); + return null; + } + // 外部安装器可能已升级程序,旧增量更新不能再覆盖当前安装。 if (isPendingUpdateObsolete(info.versionName, installedVersion)) { log.info('待安装更新不高于已安装版本,清除过期记录:', info.versionName, installedVersion); diff --git a/src/stores/types.ts b/src/stores/types.ts index d32b5bc6..7670612e 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -53,6 +53,8 @@ export interface ScheduleExecutionInfo { // 更新信息类型 export interface UpdateInfo { + /** 检查更新时的项目和基线,用于下次启动校验待安装包。 */ + origin?: { resourceId: string; baseVersion: string }; hasUpdate: boolean; versionName: string; releaseNote: string; diff --git a/tests/pendingUpdate.test.ts b/tests/pendingUpdate.test.ts new file mode 100644 index 00000000..458c5697 --- /dev/null +++ b/tests/pendingUpdate.test.ts @@ -0,0 +1,256 @@ +import { after, before, beforeEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import ts from 'typescript'; +import { createServer, type ViteDevServer } from 'vite'; +import type { UpdateInfo } from '../src/stores/types.ts'; +import type { PendingUpdateInfo } from '../src/services/updateService.ts'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const storage = new Map(); +const pendingKey = 'mxu-pending-update'; +const actualSavePath = '/fixture/cache/redirected-package.zip'; +const origin = { resourceId: 'Alpha', baseVersion: '1.0.0' }; +const update: UpdateInfo = { + origin, + hasUpdate: true, + versionName: '3.0.0', + releaseNote: 'Release notes', + downloadUrl: 'https://example.invalid/package.zip', + downloadSource: 'mirrorchyan', + updateType: 'incremental', + fileSize: 42, +}; +let server: ViteDevServer; +let service: typeof import('../src/services/updateService.ts'); +let store: typeof import('../src/stores/appStore.ts'); +let packageExists = true; +let checkedPaths: string[] = []; + +before(async () => { + Object.assign(globalThis, { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + window: Object.assign(new EventTarget(), { + location: new URL('http://localhost/'), + __TAURI_INTERNALS__: { + invoke: async (command: string, args: { path: string }) => { + if (command === 'plugin:fs|exists') { + checkedPaths.push(args.path); + return packageExists; + } + if (command === 'get_github_release_by_version') { + return { + assets: [ + { + name: 'Alpha-win-x64-v3.0.0.zip', + browser_download_url: 'https://example.invalid/full.zip', + size: 100, + }, + ], + }; + } + throw new Error(`Unexpected native operation: ${command}`); + }, + }, + }), + }); + const httpId = '\0pending-update-http'; + server = await createServer({ + root, + configFile: false, + cacheDir: join(root, '.cache', 'pending-update-tests'), + optimizeDeps: { noDiscovery: true, include: [] }, + resolve: { + alias: [ + { find: '@tauri-apps/plugin-http', replacement: httpId }, + { find: '@', replacement: join(root, 'src') }, + ], + }, + server: { middlewareMode: true, hmr: false }, + appType: 'custom', + plugins: [ + { + name: 'pending-update-http-fixture', + resolveId: (id) => (id === httpId ? httpId : undefined), + load: (id) => + id === httpId + ? `export async function fetch() { + return Response.json({ code: 0, data: { + version_name: '3.0.0', url: 'https://example.invalid/package.zip', + update_type: 'incremental', release_note: 'Release notes' + }}); + }` + : undefined, + }, + ], + }); + service = await server.ssrLoadModule('/src/services/updateService.ts'); + store = await server.ssrLoadModule('/src/stores/appStore.ts'); +}); + +after(async () => { + await server?.close(); +}); + +beforeEach(() => { + storage.clear(); + packageExists = true; + checkedPaths = []; + store.useAppStore.setState({ + backendOS: 'windows', + backendArch: 'x86_64', + downloadStatus: 'idle', + downloadSavePath: null, + mirrorChyanSettings: { cdk: '', channel: 'stable' }, + }); +}); + +test('both download sources retain the RID and exact version used to request the update', async () => { + for (const cdk of ['test-cdk', undefined]) { + const result = await service.checkAndPrepareDownload({ + resourceId: origin.resourceId, + currentVersion: origin.baseVersion, + cdk, + githubUrl: 'https://github.com/example/Alpha', + }); + assert.deepEqual(result?.origin, origin); + assert.equal(result.downloadSource, cdk ? 'mirrorchyan' : 'github'); + } +}); + +test('startup restores the same project and base while rejecting unrelated or legacy packages', async () => { + const pending: PendingUpdateInfo = { + origin, + versionName: '3.0.0', + releaseNote: '', + downloadSavePath: actualSavePath, + timestamp: 1, + }; + for (const [name, record, version, resourceId] of [ + ['legacy', { ...pending, origin: undefined }, '1.0.0', 'Alpha'], + ['changed base', pending, '2.0.0', 'Alpha'], + ['changed RID', pending, '1.0.0', 'Beta'], + ['missing RID', pending, '1.0.0', undefined], + ['missing version', pending, undefined, 'Alpha'], + ] as const) { + storage.set(pendingKey, JSON.stringify(record)); + assert.equal(await service.getPendingUpdateInfo(version, resourceId), null, name); + assert.equal(storage.has(pendingKey), false, name); + assert.deepEqual(checkedPaths, [], 'invalid origins are rejected before accessing the package'); + } + + service.savePendingUpdateInfo(pending); + assert.deepEqual(await service.getPendingUpdateInfo('1.0.0', 'Alpha'), pending); + assert.equal(storage.has(pendingKey), true, 'a valid package remains available for installation'); + assert.deepEqual(checkedPaths, [actualSavePath]); +}); + +test('a matching origin still checks target version and package existence', async () => { + const pending: PendingUpdateInfo = { + origin, + versionName: '1.0.0', + releaseNote: '', + downloadSavePath: actualSavePath, + timestamp: 1, + }; + service.savePendingUpdateInfo(pending); + assert.equal(await service.getPendingUpdateInfo('1.0.0', 'Alpha'), null); + assert.equal(storage.has(pendingKey), false); + + service.savePendingUpdateInfo({ ...pending, versionName: '3.0.0' }); + packageExists = false; + assert.equal(await service.getPendingUpdateInfo('1.0.0', 'Alpha'), null); + assert.equal(storage.has(pendingKey), false); + + service.savePendingUpdateInfo({ ...pending, versionName: 'nightly-42' }); + packageExists = true; + assert.equal((await service.getPendingUpdateInfo('1.0.0', 'Alpha'))?.versionName, 'nightly-42'); +}); + +// Execute the production callbacks without mounting the full desktop UI. Only their native +// download dependency is replaced; persistence and Zustand subscriptions remain real. +function downloadCallback(file: string, name: string, dependencies: Record) { + const source = ts.createSourceFile( + file, + readFileSync(join(root, file), 'utf8'), + ts.ScriptTarget.Latest, + true, + ); + let callback: ts.Expression | undefined; + function visit(node: ts.Node) { + if ( + ts.isVariableDeclaration(node) && + node.name.getText(source) === name && + node.initializer && + ts.isCallExpression(node.initializer) + ) { + callback = node.initializer.arguments[0]; + } + ts.forEachChild(node, visit); + } + visit(source); + assert.ok(callback, `Missing download callback ${name}`); + const code = ts.transpileModule( + `const callback = ${callback.getText(source)}; return callback;`, + { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, + }, + ).outputText; + return new Function(...Object.keys(dependencies), code)(...Object.values(dependencies)) as ( + info: UpdateInfo, + ) => Promise; +} + +for (const [file, callback] of [ + ['src/App.tsx', 'startAutoDownload'], + ['src/components/UpdatePanel.tsx', 'startDownload'], + ['src/components/settings/UpdateSection.tsx', 'startDownload'], +]) { + test(`${file} persists the actual package and origin before announcing completion`, async () => { + const announced: PendingUpdateInfo[] = []; + const unsubscribe = store.useAppStore.subscribe((state, previous) => { + if (state.downloadStatus === 'completed' && previous.downloadStatus !== 'completed') { + assert.equal(state.downloadSavePath, actualSavePath); + announced.push(JSON.parse(storage.get(pendingKey)!)); + } + }); + const logger = { info() {}, warn() {}, error() {} }; + const run = downloadCallback(file, callback, { + ...store.useAppStore.getState(), + useAppStore: store.useAppStore, + updateInfo: update, + downloadStartedRef: { current: false }, + getUpdateSavePath: async () => '/fixture/cache/requested.zip', + proxySettingsForUpdateDownload: () => undefined, + downloadUpdate: async () => ({ success: true, actualSavePath }), + completeUpdateDownload: service.completeUpdateDownload, + tryAutoInstallUpdate() {}, + log: logger, + loggers: { ui: logger }, + }); + try { + await run(update); + assert.equal(announced.length, 1); + assert.deepEqual(announced[0], { + origin, + versionName: update.versionName, + releaseNote: update.releaseNote, + downloadSavePath: actualSavePath, + fileSize: update.fileSize, + updateType: update.updateType, + downloadSource: update.downloadSource, + timestamp: announced[0].timestamp, + }); + assert.ok(announced[0].timestamp > 0); + assert.deepEqual(await service.getPendingUpdateInfo('1.0.0', 'Alpha'), announced[0]); + } finally { + unsubscribe(); + } + }); +}