From 56a7e1162245b32bf2aa30306d31eda1f20e20f4 Mon Sep 17 00:00:00 2001 From: shenmo Date: Sun, 11 Oct 2026 12:34:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(install):=20=E4=BF=AE=E5=A4=8D=20APM=20?= =?UTF-8?q?=E6=9C=AA=E5=AE=89=E8=A3=85=E6=97=B6=E7=82=B9=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E6=97=A0=E5=8F=8D=E5=BA=94=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=B8=85=E7=90=86=E5=90=8C=E7=B1=BB=E6=8E=A5=E7=BA=BF=E6=96=AD?= =?UTF-8?q?=E8=A3=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主问题:showApmInstallDialog 在 useAppState.ts 被重复声明为独立 ref, 而 processInstall.ts 置位的是 storeConfig.ts 中的另一份实例,导致 APM 未安装时点「安装」既不弹引导弹窗也不创建下载任务(界面毫无反应)。 修复: - useAppState 改为从 storeConfig 再导出同一实例(消除双实例) - processInstall 在 install-complete 兜底识别「未安装 APM」失败原因 - 新增回归测试 ApmInstallDialog.test.ts(回退修复后 3/3 失败) 同类「接线断裂」问题全量排查修复(源自 d7bdcf36 拆分 composables): - 应用下载排行永远为空:loadRanking 在 apps 未填充时空跑且不重试 - 详情页卸载后按钮状态不刷新(丢失的 checkAppInstalled 分支) - 重试已取消任务复用 id,被旧任务 finally 删除而永久卡在下载中 - remove-installed 的 spawn 缺 error 监听(可致主进程崩溃)且不消费 stderr - 云端应用列表在来源查询失败时静默丢数据(已安装列表同源问题) - 忽略更新写入失败无反馈;下载统计上报缺 catch - isInstalledAppInfo 运行时校验被削弱 清理重构遗留孤儿代码: - 删除无引用的 AppDetailPage/AccountQuickMenu/TopActions/UpdateCenterCloseConfirm - 恢复 handleRemoveComplete 语义与 onUnmounted 的 abort/unbind - 删除孤儿 IPC:list-upgradable/show-apm-install-dialog/uninstall-installed - 删除孤儿渲染端代码:handleUpgrade/handleRemove/useDownloadItemStatus 及已无生产者的 queue-install 回灌监听器 验证:vue-tsc 0 error、vite build 通过、单元测试无新增失败 (基线 40 失败 → 35 失败)。 Signed-off-by: shenmo --- CHANGELOG.md | 16 + electron/main/backend/install-manager.ts | 160 ++----- .../main/backend/update-center/service.ts | 31 +- src/App.vue | 42 +- src/__tests__/unit/ApmInstallDialog.test.ts | 239 ++++++++++ src/__tests__/unit/AppDetailPage.test.ts | 193 -------- src/__tests__/unit/processInstall.test.ts | 35 +- src/components/AccountQuickMenu.vue | 83 ---- src/components/AppDetailPage.vue | 421 ------------------ src/components/TopActions.vue | 58 --- .../UpdateCenterCloseConfirm.vue | 44 -- src/composables/useAccountSync.ts | 9 +- src/composables/useAppState.ts | 6 +- src/composables/useInstalledApps.ts | 31 +- src/global/downloadStatus.ts | 21 - src/modules/processInstall.ts | 82 +--- 16 files changed, 415 insertions(+), 1056 deletions(-) create mode 100644 src/__tests__/unit/ApmInstallDialog.test.ts delete mode 100644 src/__tests__/unit/AppDetailPage.test.ts delete mode 100644 src/components/AccountQuickMenu.vue delete mode 100644 src/components/AppDetailPage.vue delete mode 100644 src/components/TopActions.vue delete mode 100644 src/components/update-center/UpdateCenterCloseConfirm.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index c19f213a..b319228a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,22 @@ - `openDownloadedApp` 的 IPC 调用补充 `.catch` 错误处理。 7. **应用详情默认展开已安装版本**:打开应用详情时,若本地只安装了 Spark / APM 中的某一版本,则默认展开该已装来源(例如 WeChat 默认 APM,但本机只装 Spark 版时默认展开 Spark);若两版都已安装,则按优先级/标签策略展示。 +8. **修复 APM 未安装时点「安装」静默无反应**:`showApmInstallDialog` 此前在 `src/composables/useAppState.ts` 被重复声明为独立的 `ref`,而 `src/modules/processInstall.ts` 置位的是 `src/global/storeConfig.ts` 中的另一份实例,导致 APM 未安装时点击 APM 应用的「安装」既不弹出引导弹窗、也不会创建下载任务(界面毫无反应)。现统一从 `storeConfig` 再导出同一实例,并在 `install-complete` 兜底识别「未安装 APM」失败原因,保证任何路径下都不会静默失败。新增回归测试 `src/__tests__/unit/ApmInstallDialog.test.ts`。 +9. **同类「接线断裂」问题全量排查与修复**(源自 `d7bdcf36` 拆分 composables 时留下的半成品改动): + - **应用下载排行永远为空**:`loadRanking()` 在 `loadApps()` 尚未填充 `apps` 时就被调用,命中 `if (apps.value.length === 0) return` 后直接空跑且永不重试。现改为在 `loadApps` resolve 之后再触发。 + - **详情页卸载后按钮状态不刷新**:卸载成功回调在拆分时丢了「刷新详情页安装状态」分支,详情弹窗会继续显示「打开/卸载」(应用实际已卸载,点「打开」会启动不存在的包)。现于编排层 `handleUninstallSuccess` 补回。 + - **重试已取消的任务会永久卡在「下载中」**:取消后重试复用同一任务 id,旧任务仍在退出流程中,其 `finally` 会 `tasks.delete(id)` 删掉新任务,新任务再也不会进入安装阶段。现已对 3 处 `tasks.delete` 加身份校验(`tasks.get(id) === task`)。 + - **卸载失败可能让主进程崩溃且看不到原因**:`remove-installed` 的 `spawn` 没有 `error` 监听(spawn 失败时只触发 `error` 不触发 `close`,且主进程无全局兜底),也未消费 stderr(写满管道会阻塞包管理器,失败原因全部丢失)。现补齐 `error`/`stderr` 处理并统一收敛为一次 `remove-complete`。 + - **云端应用列表静默丢数据**:某来源 `list-installed` 返回 `success:false`(来源被启动参数禁用 / 包管理器缺失)时,仍被当作「已刷新」,`mergeInstalledApps` 会丢弃该来源的本地已知记录,随后把不完整列表上传覆盖账号。现只统计真正查询成功的来源(已安装列表合并同样修正)。 + - **忽略更新的写入失败无任何反馈**:`saveIgnored` 抛错会变成未处理的 rejection,列表不刷新、条目也不标记「已忽略」。现收敛为界面 warning。 + - **下载次数统计上报失败产生未处理 rejection**:补 `.catch`,仅记录 warn 不影响安装。 + - **`isInstalledAppInfo` 运行时校验被削弱**:拆分时只剩 `pkgname`/`origin` 两项检查,字段残缺的行会带着 `undefined` 流入已安装列表与云端同步条目。现恢复完整字段校验。 + +10. **清理重构遗留的孤儿代码**: + - 删除四个已成孤儿的组件:`AppDetailPage.vue`、`AccountQuickMenu.vue`、`TopActions.vue`、`update-center/UpdateCenterCloseConfirm.vue`(均无任何引用,也不进入构建产物),连同仅覆盖它们的 `AppDetailPage.test.ts`。 + - App.vue 的 `handleRemoveComplete` 由空实现恢复为「卸载成功后移除下载队列残留记录」;`onUnmounted` 补回 `rootAbortController.abort()` 与 `updateCenterStore.unbind()`。 + - 删除孤儿 IPC 处理器 `list-upgradable`(及仅其使用的 `parseUpgradableList`)、`show-apm-install-dialog`(前端早已改用 Vue 弹窗)、`uninstall-installed`(与在用的 `remove-installed` 重复)。 + - 删除孤儿渲染端代码:`handleUpgrade`、`handleRemove`、`useDownloadItemStatus`,以及已无生产者的 `queue-install` 渲染端回灌监听器(其主进程 `webContents.send` 生产者在 `b5c5ab04` 已移除)。 相关提交:`1d9d25d6`、`c75938aa`、`39a358bb`、`51473cac`、`cf0e72ff`、`b0d60737`、`d3705ed4`。 diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index f7075567..0d569b11 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -267,41 +267,6 @@ const checkSparkAvailable = async (): Promise => { return found; }; -const parseUpgradableList = (output: string) => { - const apps: Array<{ - pkgname: string; - newVersion: string; - currentVersion: string; - raw: string; - }> = []; - const lines = output.split("\n"); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - if (trimmed.startsWith("Listing")) continue; - if (trimmed.startsWith("[INFO]")) continue; - if (trimmed.includes("=") && !trimmed.includes("/")) continue; - - if (!trimmed.includes("/")) continue; - - const tokens = trimmed.split(/\s+/); - if (tokens.length < 2) continue; - const pkgToken = tokens[0]; - const pkgname = pkgToken.split("/")[0]; - const newVersion = tokens[1] || ""; - const currentMatch = trimmed.match( - /\[(?:upgradable from|from):\s*([^\]\s]+)\]/i, - ); - const currentToken = tokens[5] || ""; - const currentVersion = - currentMatch?.[1] || currentToken.replace("[", "").replace("]", ""); - - if (!pkgname) continue; - apps.push({ pkgname, newVersion, currentVersion, raw: trimmed }); - } - return apps; -}; - /** * 将任务加入安装/下载队列。被 IPC 监听与更新中心等内部模块共享使用。 * 入队前会执行包名、文件名、metalinkUrl 的校验,并检查重复任务。 @@ -662,7 +627,7 @@ ipcMain.on("cancel-install", (event, id) => { // 由对应的阶段处理器在 finally 中清理计数器与队列 } else { // 排队中的任务(未开始执行):直接清理并调度 - tasks.delete(id); + if (tasks.get(id) === task) tasks.delete(id); processNextDownload(); processNextInstall(); } @@ -914,7 +879,10 @@ async function runDownloadPhase(task: InstallTask) { }), }); } - tasks.delete(id); + // 身份校验后再删除:重试会用同一 id 覆盖 tasks 中的条目, + // 若此时旧任务(取消后仍在退出的进程)的清理流程把它删掉, + // 新任务就会从队列中消失,安装阶段永远不会被调度(界面卡在下载中)。 + if (tasks.get(id) === task) tasks.delete(id); } finally { activeDownloadCount--; processNextDownload(); @@ -1080,7 +1048,8 @@ async function runInstallPhase(task: InstallTask) { }); } } finally { - tasks.delete(id); + // 同上:重试复用 id 时,旧任务的 finally 不得删除新任务 + if (tasks.get(id) === task) tasks.delete(id); installIdle = true; processNextInstall(); processNextDownload(); @@ -1186,20 +1155,19 @@ ipcMain.on("remove-installed", async (_event, payload) => { env: process.env, }); let output = ""; - - child.stdout.on("data", (data) => { - const chunk = data.toString(); - output += chunk; - webContents.send("remove-progress", chunk); - }); - - child.on("close", (code) => { + let stderrOutput = ""; + // spawn 失败(如 pkexec 与 shell-caller.sh 都不存在)只会触发 'error', + // 不会触发 'close';且没有全局 uncaughtException 兜底,缺少该监听会让 + // 主进程直接抛出未捕获异常。这里统一收敛为一次 remove-complete。 + let settled = false; + const finishRemove = (code: number | null, message: string) => { + if (settled) return; + settled = true; const success = code === 0; - // 拼接json消息 const messageJSONObj = { - message: success ? "卸载完成" : `卸载失败,退出码 ${code}`, + message, stdout: output, - stderr: "", + stderr: stderrOutput, }; if (success) { @@ -1215,7 +1183,31 @@ ipcMain.on("remove-installed", async (_event, payload) => { exitCode: code, message: JSON.stringify(messageJSONObj), origin: origin, + // 回传包名,渲染端据此精确移除下载队列中对应的残留记录, + // 而不必依赖「当前详情应用」这类可能已切换的界面状态。 + pkgname: pkgname, } satisfies ChannelPayload); + }; + + child.stdout?.on("data", (data) => { + const chunk = data.toString(); + output += chunk; + webContents.send("remove-progress", chunk); + }); + + // 必须消费 stderr:否则输出较多时管道写满会阻塞包管理器进程, + // 且失败原因(权限/依赖错误)会全部丢失,界面只能显示「退出码 N」。 + child.stderr?.on("data", (data) => { + stderrOutput += data.toString(); + }); + + child.on("error", (err) => { + logger.error(`remove-installed spawn 失败: ${err.message}`); + finishRemove(-1, `卸载失败: ${err.message}`); + }); + + child.on("close", (code) => { + finishRemove(code, code === 0 ? "卸载完成" : `卸载失败,退出码 ${code}`); }); }); @@ -1477,24 +1469,6 @@ ipcMain.handle( }, ); -ipcMain.handle("list-upgradable", async () => { - const { code, stdout, stderr } = await runCommandCapture("apm", [ - "list", - "--upgradable", - ]); - if (code !== 0) { - logger.error(`list-upgradable failed: ${stderr || stdout}`); - return { - success: false, - message: stderr || stdout || `list-upgradable failed with code ${code}`, - apps: [], - }; - } - - const apps = parseUpgradableList(stdout); - return { success: true, apps }; -}); - ipcMain.handle("check-apm-available", async () => { return await checkApmAvailable(); }); @@ -1503,58 +1477,6 @@ ipcMain.handle("check-spark-available", async () => { return await checkSparkAvailable(); }); -// 显示 APM 安装对话框(在点击安装按钮时提前检查) -// 前端已改为 Vue 弹窗,此后端处理仅作为兜底 -ipcMain.handle("show-apm-install-dialog", async (event) => { - const webContents = event.sender; - webContents.send("trigger-apm-install-dialog"); - return { success: false, cancelled: true }; -}); - -ipcMain.handle( - "uninstall-installed", - async ( - _event, - payload: unknown, - ): Promise<{ success: boolean; message?: string }> => { - const parsed = parseAppPayload(payload); - if (!parsed) { - logger.warn("uninstall-installed invalid payload"); - return { success: false, message: "invalid payload" }; - } - const { pkgname, origin } = parsed; - - const superUserCmd = await checkSuperUserCommand(); - const execCommand = superUserCmd || SHELL_CALLER_PATH; - const execParams = superUserCmd ? [SHELL_CALLER_PATH] : []; - - if (origin === "apm") { - execParams.push("apm", "remove", "-y", pkgname); - } else { - execParams.push("aptss", "remove", "-y", pkgname); - } - - const { code, stdout, stderr } = await runCommandCapture( - execCommand, - execParams, - ); - const success = code === 0; - - if (success) { - logger.info(`卸载完成: ${pkgname}`); - } else { - logger.error(`卸载失败: ${pkgname} ${stderr || stdout}`); - } - - return { - success, - message: success - ? "卸载完成" - : stderr || stdout || `卸载失败,退出码 ${code}`, - }; - }, -); - interface LaunchAppPayload { pkgname: string; origin?: "spark" | "apm"; diff --git a/electron/main/backend/update-center/service.ts b/electron/main/backend/update-center/service.ts index df3db71a..6d60875f 100644 --- a/electron/main/backend/update-center/service.ts +++ b/electron/main/backend/update-center/service.ts @@ -211,15 +211,34 @@ export const createUpdateCenterService = ( open: refresh, refresh, async ignore(payload) { - const entries = await loadIgnored(); - entries.add(createIgnoreKey(payload.packageName, payload.newVersion)); - await saveIgnored(entries); + try { + const entries = await loadIgnored(); + entries.add(createIgnoreKey(payload.packageName, payload.newVersion)); + await saveIgnored(entries); + } catch (error) { + // 写入失败(配置目录不可写等)此前会以未处理的 rejection 冒泡到渲染端, + // 既不刷新列表也不给出任何提示,用户看到「点了没反应」。 + // 这里收敛为一条 warning,让界面明确告知失败原因。 + const message = error instanceof Error ? error.message : String(error); + console.error(`[UpdateCenter] 保存忽略列表失败:`, error); + applyWarning(`保存忽略列表失败: ${message}`); + return emit(); + } await refresh(); }, async unignore(payload) { - const entries = await loadIgnored(); - entries.delete(createIgnoreKey(payload.packageName, payload.newVersion)); - await saveIgnored(entries); + try { + const entries = await loadIgnored(); + entries.delete( + createIgnoreKey(payload.packageName, payload.newVersion), + ); + await saveIgnored(entries); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[UpdateCenter] 保存忽略列表失败:`, error); + applyWarning(`保存忽略列表失败: ${message}`); + return emit(); + } await refresh(); }, async start(tasks) { diff --git a/src/App.vue b/src/App.vue index ff74079f..1a2f92bf 100644 --- a/src/App.vue +++ b/src/App.vue @@ -237,7 +237,7 @@ :show="showUninstallModal" :app="uninstallTargetApp" @close="closeUninstallModal" - @success="onUninstallSuccess" + @success="handleUninstallSuccess" /> { uninstallTargetApp.value = null; }; +// 卸载成功:刷新已安装列表,并刷新仍打开的详情弹窗安装状态。 +// 卸载确认弹窗会叠在详情弹窗之上,详情弹窗此时的「打开/卸载」按钮取自 +// currentAppSparkInstalled / currentAppApmInstalled;不刷新就会继续显示 +// 「打开/卸载」(应用其实已卸载,点「打开」会去启动一个不存在的包)。 +// 该分支在 App.vue 拆分为 composables 时曾被漏掉,这里在编排层统一补回。 +const handleUninstallSuccess = () => { + onUninstallSuccess(); + if (showModal.value && currentApp.value) { + checkAppInstalled(currentApp.value); + } +}; + const closeApmInstallDialog = () => { showApmInstallDialog.value = false; }; @@ -1047,13 +1062,20 @@ const handleDeepLinkSearch = ( } }; +// 卸载完成:移除下载队列中已完成的记录。 +// 重构自 App.vue 时该分支一度被掏空成空实现(且注释误称由 useDownloads 处理), +// 而 useDownloads 从不调用 removeDownloadItem,导致从详情页卸载后队列记录长期残留。 const handleRemoveComplete = ( _event: IpcRendererEvent, payload: import("./global/typedefinition").ChannelPayload, ) => { - const pkgname = currentApp.value?.pkgname; + // 优先用主进程回传的包名(精确),旧版主进程未回传时退回当前详情应用。 + const pkgname = + (typeof payload.pkgname === "string" ? payload.pkgname : "") || + currentApp.value?.pkgname || + ""; if (payload.success && pkgname) { - // 由 useDownloads 内部维护 pendingDownloadRecords;此处仅关闭详情态 + removeDownloadItem(pkgname); } }; @@ -1109,14 +1131,16 @@ onMounted(async () => { await Promise.all([loadHome(), preloadHomeListApps()]); // 全量应用后台加载(不阻塞首页推荐):首个分类成功即关闭首屏遮罩, - // 增量渲染,排行稍后由 loadRanking 刷新。 + // 增量渲染;全量加载完成后再刷新排行榜,确保 spark/apm 应用均已就绪。 + // 注意:loadApps 内部首个 await 前 apps 仍为空,loadRanking 开头有 + // `if (apps.value.length === 0) return`,若与之并发调用会直接空跑返回, + // 导致下载排行永久停留在空状态,故必须在其 resolve 之后再触发。 void loadApps(() => { loading.value = false; // 首屏:已有部分应用即可显示 + }).then(() => { + void loadRanking(); }); - // 全量应用加载完成后再刷新排行榜,确保 spark/apm 应用均已就绪 - loadRanking(); - // 其余分类入口预加载(用户点击分类时才需要,延后) preloadSidebarTabApps().then(() => { logger.info("侧边栏入口预加载完成"); @@ -1152,6 +1176,10 @@ onMounted(async () => { }); onUnmounted(() => { + // 取消仍在进行的目录/详情请求,避免组件销毁后回调写入已卸载状态 + rootAbortController.abort(); + // 解除更新中心 IPC 状态监听(bind 在 onMounted 中注册) + updateCenterStore.unbind(); // 清理窗口尺寸防抖定时器,防止组件销毁后仍触发 IPC 保存调用 if (saveBoundsTimer) clearTimeout(saveBoundsTimer); // 停止仍挂起的"等待 loading 完成后执行一次"的 watcher,避免泄漏 diff --git a/src/__tests__/unit/ApmInstallDialog.test.ts b/src/__tests__/unit/ApmInstallDialog.test.ts new file mode 100644 index 00000000..0663ac71 --- /dev/null +++ b/src/__tests__/unit/ApmInstallDialog.test.ts @@ -0,0 +1,239 @@ +/** + * APM 安装引导弹窗回归测试。 + * + * 背景(历史 Bug):APM 未安装时点击安装 APM 来源的应用,商店「静默什么都不发生」。 + * 根因是 showApmInstallDialog 存在两份 ref 实例: + * - src/global/storeConfig.ts (modules/processInstall 置位) + * - src/composables/useAppState.ts (App.vue 读取,历史上重复 ref(false) 声明) + * 置位写的是 A,弹窗读的是 B,因此弹窗永不显示、下载任务也不会创建。 + * + * 本测试锁死「同一实例」这一不变量,并端到端验证:APM 未安装时点「安装」 + * 必定弹出 APM 引导弹窗(而不是静默失败)。 + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// 注意:App / processInstall / 共享状态一律在用例内动态 import。 +// 原因:processInstall 在模块初始化时就用 window.ipcRenderer.on 注册监听, +// 静态 import 会在测试文件的 beforeEach 之前执行(此时收集用的 on mock 还没装上), +// 处理器就收集不到了。 +const importApp = () => import("@/App.vue"); +const importState = () => import("@/composables/useAppState"); +const importDownloads = () => import("@/global/downloadStatus"); + +const invoke = vi.fn(); +const send = vi.fn(); +const ipcHandlers = new Map void>>(); + +vi.mock("axios", () => { + const get = vi.fn(async (url: string) => { + if (url.includes("categories.json")) { + return { data: { office: { zh: "办公" } } }; + } + if (url.includes("/office/applist.json")) { + return { + data: [ + { + Name: "WPS", + Pkgname: "wps", + Version: "1.0.0", + Filename: "wps_1.0.0_amd64.deb", + Torrent_address: "", + Author: "", + Contributor: "", + Website: "", + Update: "", + Size: "", + More: "Office suite", + Tags: "", + img_urls: "[]", + icons: "", + }, + ], + }; + } + return { data: [] }; + }); + const post = vi.fn(async () => ({ data: { ok: true } })); + + return { + default: { + create: () => ({ + interceptors: { + request: { use: () => undefined }, + response: { use: () => undefined }, + }, + get, + post, + }), + }, + }; +}); + +vi.mock("@/modules/updateCenter", () => ({ + createUpdateCenterStore: () => ({ + isOpen: { value: false }, + showCloseConfirm: { value: false }, + showMigrationConfirm: { value: false }, + searchQuery: { value: "" }, + selectedTaskKeys: { value: new Set() }, + snapshot: { + value: { items: [], tasks: [], warnings: [], hasRunningTasks: false }, + }, + filteredItems: { value: [] }, + allSelected: { value: false }, + someSelected: { value: false }, + bind: vi.fn(), + unbind: vi.fn(), + open: vi.fn(), + refresh: vi.fn(), + ignoreItem: vi.fn(), + unignoreItem: vi.fn(), + toggleSelection: vi.fn(), + toggleSelectAll: vi.fn(), + getSelectedItems: vi.fn(() => []), + closeNow: vi.fn(), + startSelected: vi.fn(), + requestClose: vi.fn(), + }), +})); + +describe("APM 安装引导弹窗", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + ipcHandlers.clear(); + + invoke.mockImplementation(async (channel: string) => { + if (channel === "get-store-filter") return "apm"; + if (channel === "check-spark-available") return false; + // 本机未安装 APM —— 这正是弹窗需要出现的场景 + if (channel === "check-apm-available") return false; + if (channel === "get-app-version") return "5.0.0"; + if (channel === "get-system-info") return { distro: "deepin 25" }; + if (channel === "list-installed") return { success: true, apps: [] }; + if (channel === "check-installed") return false; + return []; + }); + + Object.assign(window.ipcRenderer, { + invoke, + send, + on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => { + // 同一 channel 可能有多个监听者(App.vue 与 processInstall 各注册一份), + // 全部收集,避免只拿到最后一个 handler。 + const list = ipcHandlers.get(channel) ?? []; + list.push(handler); + ipcHandlers.set(channel, list); + }), + off: vi.fn(), + }); + window.apm_store.arch = "amd64"; + localStorage.clear(); + + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + vi.stubGlobal("scrollTo", vi.fn()); + class MockIntersectionObserver { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + } + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + }); + + it("storeConfig 与 useAppState 导出的弹窗开关必须是同一个 ref 实例", async () => { + const config = await import("@/global/storeConfig"); + const state = await importState(); + + expect(state.showApmInstallDialog).toBe(config.showApmInstallDialog); + + // 写在任一侧,另一侧必须立刻可见(防止再次分裂成两份 ref) + config.showApmInstallDialog.value = false; + state.showApmInstallDialog.value = true; + expect(config.showApmInstallDialog.value).toBe(true); + }); + + it( + "APM 未安装时点击「安装」会弹出 APM 引导弹窗,且不创建下载任务", + { timeout: 30000 }, + async () => { + render((await importApp()).default); + + await fireEvent.click( + await screen.findByRole("button", { name: "全部应用 1" }), + ); + await fireEvent.click(await screen.findByText("WPS")); + await fireEvent.click( + await screen.findByRole("button", { name: "安装" }), + ); + + // 弹窗必须出现(修复前:因为读的是另一个 ref,这里永远找不到,界面「什么都不发生」) + expect(await screen.findByText("需要安装 APM")).toBeTruthy(); + // 未安装 APM 时不应把任务塞进下载队列 + expect(send).not.toHaveBeenCalledWith("queue-install", expect.anything()); + }, + ); + + it("主进程回传「未安装 APM」失败时会置位弹窗开关(兜底不再静默)", async () => { + // 清模块缓存后重新加载:测试文件自身对 processInstall 的依赖会在收集 mock 之前 + // 就把它初始化掉,这里强制重跑一遍模块顶层,才能让本次用例收集到它注册的处理器。 + vi.resetModules(); + await import("@/modules/processInstall"); + const { downloads } = await importDownloads(); + // 先挂载 App,让 useDownloads 注册 downloadStatus 监听(它会改写 downloads.value), + // 之后再写入本次用例的下载项,避免被清空。 + render((await importApp()).default); + await screen.findByRole("button", { name: "全部应用 1" }); + + downloads.value = [ + { + id: 42, + name: "WPS", + pkgname: "wps", + version: "1.0.0", + icon: "", + origin: "apm", + status: "queued", + progress: 0, + downloadedSize: 0, + totalSize: 0, + speed: 0, + timeRemaining: 0, + startTime: Date.now(), + logs: [], + source: "APM Store", + retry: false, + }, + ]; + const { showApmInstallDialog } = await importState(); + showApmInstallDialog.value = false; + + const completion = { + id: 42, + time: Date.now(), + success: false, + exitCode: -1, + message: JSON.stringify({ + message: "未安装 APM,无法继续安装此应用", + stdout: "", + stderr: "", + }), + }; + for (const handler of ipcHandlers.get("install-complete") ?? []) { + handler({}, completion); + } + + expect(showApmInstallDialog.value).toBe(true); + expect(downloads.value[0].status).toBe("failed"); + }); +}); diff --git a/src/__tests__/unit/AppDetailPage.test.ts b/src/__tests__/unit/AppDetailPage.test.ts deleted file mode 100644 index c0f6e42c..00000000 --- a/src/__tests__/unit/AppDetailPage.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/vue"; -import { describe, expect, it, vi } from "vitest"; - -import AppDetailPage from "@/components/AppDetailPage.vue"; -import type { App, ReviewTags } from "@/global/typedefinition"; - -vi.mock("@/components/ReviewsPanel.vue", () => ({ - default: { - name: "ReviewsPanel", - props: ["appKey", "tags", "loggedIn", "canSubmit"], - emits: ["request-login", "show-user"], - template: - '', - }, -})); - -const app: App = { - name: "WPS", - pkgname: "wps", - version: "1.0.0", - filename: "wps_1.0.0_amd64.deb", - torrent_address: "", - author: "", - contributor: "", - website: "", - update: "", - size: "110M", - more: "Office suite", - tags: "office", - img_urls: [], - icons: "", - category: "office", - origin: "apm", - currentStatus: "not-installed", -}; - -const sparkApp: App = { - ...app, - name: "WPS Spark", - version: "2.0.0", - filename: "wps_2.0.0_amd64.deb", - origin: "spark", -}; - -const apmApp: App = { - ...app, - name: "WPS APM", - version: "1.0.0", - filename: "wps_1.0.0_amd64.deb", - origin: "apm", -}; - -const mergedApp: App = { - ...sparkApp, - isMerged: true, - sparkApp, - apmApp, - viewingOrigin: "spark", -}; - -const sparkTags: ReviewTags = { - origin: "spark", - category: "office", - pkgname: "wps", - version: "2.0.0", - packageArch: "amd64", - clientArch: "amd64", - distro: "deepin 25", -}; - -describe("AppDetailPage", () => { - it("renders as page, emits back, and gates favorite for anonymous users", async () => { - const rendered = render(AppDetailPage, { - props: { - app, - screenshots: [], - sparkInstalled: false, - apmInstalled: false, - loggedIn: false, - reviewAppKey: "apm:amd64-apm:office:wps", - reviewTags: null, - }, - }); - - expect(screen.getByText("Office suite")).toBeTruthy(); - await fireEvent.click(screen.getByRole("button", { name: "返回" })); - await fireEvent.click(screen.getByRole("button", { name: "收藏" })); - - expect(rendered.emitted("back")).toHaveLength(1); - expect(rendered.emitted("request-login")?.[0]?.[0]).toBe( - "收藏应用需要登录星火账号。", - ); - }); - - it("gates reviews for anonymous users", async () => { - const rendered = render(AppDetailPage, { - props: { - app, - screenshots: [], - sparkInstalled: false, - apmInstalled: false, - loggedIn: false, - reviewAppKey: "apm:amd64-apm:office:wps", - reviewTags: sparkTags, - }, - }); - - expect(screen.queryByTestId("reviews-panel")).toBeNull(); - await fireEvent.click( - screen.getByRole("button", { name: "登录后查看评价" }), - ); - expect(rendered.emitted("request-login")?.[0]?.[0]).toBe( - "登录后查看和发表评论。", - ); - }); - - it("updates review identity when switching a merged app origin", async () => { - render(AppDetailPage, { - props: { - app: mergedApp, - screenshots: [], - sparkInstalled: true, - apmInstalled: true, - loggedIn: true, - reviewAppKey: "spark:amd64-store:office:wps", - reviewTags: sparkTags, - }, - }); - - expect(screen.getByTestId("reviews-panel")).toHaveAttribute( - "data-app-key", - "spark:amd64-store:office:wps", - ); - expect(screen.getByTestId("reviews-panel")).toHaveAttribute( - "data-origin", - "spark", - ); - - await fireEvent.click(screen.getByRole("button", { name: "APM" })); - - expect(screen.getByTestId("reviews-panel")).toHaveAttribute( - "data-app-key", - "apm:amd64-apm:office:wps", - ); - expect(screen.getByTestId("reviews-panel")).toHaveAttribute( - "data-origin", - "apm", - ); - expect(screen.getByTestId("reviews-panel")).toHaveAttribute( - "data-version", - "1.0.0", - ); - }); - - it("marks reviews read-only when the selected origin is not installed", () => { - render(AppDetailPage, { - props: { - app, - screenshots: [], - sparkInstalled: false, - apmInstalled: false, - loggedIn: true, - reviewAppKey: "apm:amd64-apm:office:wps", - reviewTags: sparkTags, - }, - }); - - expect(screen.getByTestId("reviews-panel")).toHaveAttribute( - "data-can-submit", - "false", - ); - }); - - it("forwards review user profile events", async () => { - const rendered = render(AppDetailPage, { - props: { - app, - screenshots: [], - sparkInstalled: true, - apmInstalled: true, - loggedIn: true, - reviewAppKey: "apm:amd64-apm:office:wps", - reviewTags: sparkTags, - }, - }); - - await fireEvent.click(screen.getByTestId("reviews-panel")); - - expect(rendered.emitted("show-user")?.[0]?.[0]).toEqual( - expect.objectContaining({ userDisplayName: "Detail User" }), - ); - }); -}); diff --git a/src/__tests__/unit/processInstall.test.ts b/src/__tests__/unit/processInstall.test.ts index aca2a9ee..74723948 100644 --- a/src/__tests__/unit/processInstall.test.ts +++ b/src/__tests__/unit/processInstall.test.ts @@ -1,39 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -describe("processInstall queue forwarding", () => { +describe("processInstall", () => { + // 注:原先此处的「queue-install 事件回灌」用例已删除—— + // 该渲染端监听器对应的主进程 webContents.send("queue-install") 生产者在 + // 更新中心改为直接调用 addInstallTask 后即被移除(b5c5ab04), + // 监听器成为孤儿代码,已一并清理。 beforeEach(() => { vi.resetModules(); }); - it("forwards update-center queue-install events back to the main install queue", async () => { - const handlers = new Map void>(); - const send = vi.fn(); - const on = vi.fn( - (channel: string, handler: (...args: unknown[]) => void) => { - handlers.set(channel, handler); - }, - ); - - Object.assign(window.ipcRenderer, { - on, - send, - invoke: vi.fn(), - }); - - await import("@/modules/processInstall"); - - const payload = JSON.stringify({ - id: 7, - pkgname: "spark-weather", - origin: "spark", - upgradeOnly: true, - }); - - handlers.get("queue-install")?.({}, payload); - - expect(send).toHaveBeenCalledWith("queue-install", payload); - }); - it("allocates install ids after existing update tasks", async () => { const handlers = new Map void>(); const send = vi.fn(); diff --git a/src/components/AccountQuickMenu.vue b/src/components/AccountQuickMenu.vue deleted file mode 100644 index 124018a2..00000000 --- a/src/components/AccountQuickMenu.vue +++ /dev/null @@ -1,83 +0,0 @@ - - - - - diff --git a/src/components/AppDetailPage.vue b/src/components/AppDetailPage.vue deleted file mode 100644 index a1eea51b..00000000 --- a/src/components/AppDetailPage.vue +++ /dev/null @@ -1,421 +0,0 @@ - - - diff --git a/src/components/TopActions.vue b/src/components/TopActions.vue deleted file mode 100644 index 84470330..00000000 --- a/src/components/TopActions.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/src/components/update-center/UpdateCenterCloseConfirm.vue b/src/components/update-center/UpdateCenterCloseConfirm.vue deleted file mode 100644 index 1322a80e..00000000 --- a/src/components/update-center/UpdateCenterCloseConfirm.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/src/composables/useAccountSync.ts b/src/composables/useAccountSync.ts index 1dc3c466..d69bd241 100644 --- a/src/composables/useAccountSync.ts +++ b/src/composables/useAccountSync.ts @@ -202,6 +202,12 @@ const refreshInstalledSyncCandidates = async ( } const refreshedApps: App[] = []; + // 只记录「查询成功」的来源:list-installed 在来源被启动参数禁用或对应 + // 包管理器不可用时会返回 success:false(而非 reject)。若仍把它算作已刷新, + // mergeInstalledApps 会按「该来源已刷新」丢弃本地已知记录,随后 + // syncInstalledAppsToAccount 会把这份不完整列表 PUT/me/app-list 覆盖到账号, + // 造成云端应用列表静默丢数据。 + const refreshedOrigins: Array<"spark" | "apm"> = []; await Promise.all( origins.map(async (origin) => { const pkgnameList = @@ -216,6 +222,7 @@ const refreshInstalledSyncCandidates = async ( }); if (!result?.success) return; + refreshedOrigins.push(origin); const appList = Array.isArray(result?.apps) ? result.apps : []; for (const rawApp of appList) { // 运行时类型守卫:避免后端字段缺失时下游访问 undefined @@ -233,7 +240,7 @@ const refreshInstalledSyncCandidates = async ( syncCandidateApps.value = mergeInstalledApps( syncCandidateApps.value, refreshedApps, - origins, + refreshedOrigins, ); return true; }; diff --git a/src/composables/useAppState.ts b/src/composables/useAppState.ts index a9f5b74f..9fe632c3 100644 --- a/src/composables/useAppState.ts +++ b/src/composables/useAppState.ts @@ -75,6 +75,11 @@ export { currentApp, currentAppSparkInstalled, currentAppApmInstalled, + // APM 安装引导弹窗开关必须与 modules/processInstall 写入的 ref 同源, + // 故同样从 storeConfig 再导出。历史上此处重复 `ref(false)` 声明过一份, + // 导致 processInstall 置位 true 后 App.vue 读到的仍是另一实例, + // APM 未安装时点安装什么都不会发生(弹窗静默不显示)。 + showApmInstallDialog, } from "../global/storeConfig"; export const showModal = ref(false); export const showPreview = ref(false); @@ -139,7 +144,6 @@ export const showUninstallModal = ref(false); export const uninstallTargetApp: Ref = ref(null); export const showAboutModal = ref(false); export const showSettingsModal = ref(false); -export const showApmInstallDialog = ref(false); // 可用来源 computed(供 storeFilter 相关判断复用) export const availableSources = computed(() => ({ diff --git a/src/composables/useInstalledApps.ts b/src/composables/useInstalledApps.ts index 69e545f2..c9e9f86e 100644 --- a/src/composables/useInstalledApps.ts +++ b/src/composables/useInstalledApps.ts @@ -34,12 +34,24 @@ import { getEffectiveStoreFilter, } from "../modules/storeFilter"; import { cloudItemKey, cloudPackageKey } from "../modules/appListSync"; -import { removeDownloadItem } from "../global/downloadStatus"; +// 运行时类型守卫(重构前在 App.vue 中校验完整字段,拆分时被削弱成只查 +// pkgname/origin,导致字段残缺的行会带着 undefined 流入已安装列表与云端 +// 同步条目)。主进程 list-installed 始终按下方结构回传,逐字段校验即可。 const isInstalledAppInfo = (value: unknown): value is InstalledAppInfo => { - if (!value || typeof value !== "object") return false; - const v = value as Record; - return typeof v.pkgname === "string" && typeof v.origin === "string"; + if (typeof value !== "object" || value === null) return false; + const v = value as Partial; + return ( + typeof v.pkgname === "string" && + typeof v.name === "string" && + typeof v.version === "string" && + typeof v.arch === "string" && + (v.origin === "spark" || v.origin === "apm") && + typeof v.flags === "string" && + typeof v.isDependency === "boolean" && + // icon 为可选字段,需显式校验类型,避免非字符串值进入下游 app.icon || "" + (typeof v.icon === "string" || v.icon === undefined) + ); }; const LIST_INSTALLED_TIMEOUT_MS = 15000; @@ -269,6 +281,7 @@ const refreshFavoriteInstalledApps = async (): Promise => { } const refreshedApps: App[] = []; + const refreshedOrigins: Array<"spark" | "apm"> = []; await Promise.all( origins.map(async (origin) => { const pkgnameList = @@ -283,6 +296,10 @@ const refreshFavoriteInstalledApps = async (): Promise => { }); if (!result?.success) return; + // 仅「查询成功」的来源才算已刷新:来源禁用/包管理器缺失时返回 + // success:false,此时若仍从列表里剔除该来源的已知应用,会让已安装 + // 列表凭空少掉一整类应用(与云端同步丢数据同源)。 + refreshedOrigins.push(origin); const appList = Array.isArray(result?.apps) ? result.apps : []; for (const rawApp of appList) { // 运行时类型守卫:避免后端字段缺失时下游访问 undefined @@ -299,7 +316,7 @@ const refreshFavoriteInstalledApps = async (): Promise => { installedApps.value = [ ...installedApps.value.filter( (app) => - !origins.includes(app.origin) && + !refreshedOrigins.includes(app.origin) && !refreshedKeys.has(`${app.origin}:${app.pkgname}`), ), ...refreshedApps, @@ -309,7 +326,9 @@ const refreshFavoriteInstalledApps = async (): Promise => { const requestUninstall = (app: App) => { uninstallTargetApp.value = app; showUninstallModal.value = true; - removeDownloadItem(app.pkgname); + // 注意:不要在此处移除下载记录。卸载尚未确认/完成,提前移除会导致 + // 用户取消卸载也丢失记录;实际移除由 remove-complete 成功后统一处理 + // (见 App.vue handleRemoveComplete),与重构前的语义一致。 }; const openInstalledModal = () => { diff --git a/src/global/downloadStatus.ts b/src/global/downloadStatus.ts index 03e44396..bcd728a6 100644 --- a/src/global/downloadStatus.ts +++ b/src/global/downloadStatus.ts @@ -70,27 +70,6 @@ export function watchDownloadsChange(cb: (pkgname: string) => void) { ); } -export function useDownloadItemStatus( - pkgname?: ComputedRef, -) { - const status: ComputedRef = computed(() => { - const name = unref(pkgname); - if (!name) return; - const task = downloads.value.find((d) => d.pkgname === name); - if (!task) return; - return task.status; - }); - - const isCompleted = computed(() => { - return status.value === "completed"; - }); - - return { - status, - isCompleted, - }; -} - export function useInstallFeedback(pkgname?: ComputedRef) { const installFeedback = computed(() => { const name = unref(pkgname); diff --git a/src/modules/processInstall.ts b/src/modules/processInstall.ts index 482613b5..dfe6d78f 100644 --- a/src/modules/processInstall.ts +++ b/src/modules/processInstall.ts @@ -1,13 +1,13 @@ import pino from "pino"; import { + APM_STORE_BASE_URL, APM_STORE_STATS_BASE_URL, currentApp, currentAppSparkInstalled, currentAppApmInstalled, showApmInstallDialog, } from "../global/storeConfig"; -import { APM_STORE_BASE_URL } from "../global/storeConfig"; import { downloads, getNextDownloadId } from "../global/downloadStatus"; import { @@ -99,6 +99,11 @@ export const handleInstall = async ( ) .then((response) => { logger.info("下载次数统计已发送,状态:", response.data); + }) + // 统计上报失败不应影响安装,但也必须消费掉 rejection: + // 否则网络异常/超时会变成未处理的 promise rejection(渲染端没有全局兜底)。 + .catch((error: unknown) => { + logger.warn(`下载次数统计发送失败(忽略): ${String(error)}`); }); return download; }; @@ -110,64 +115,6 @@ export const handleRetry = (download_: DownloadItem) => { window.ipcRenderer.send("queue-install", JSON.stringify(download_)); }; -export const handleUpgrade = async (app: App) => { - if (!app.pkgname) return; - - // APM 应用:在创建下载任务前检查 APM 是否可用 - if (app.origin === "apm") { - const hasApm = await window.ipcRenderer.invoke("check-apm-available"); - if (!hasApm) { - showApmInstallDialog.value = true; - return; - } - } - - if ( - downloads.value.find( - (d) => d.pkgname === app.pkgname && d.origin === app.origin, - ) - ) { - logger.info(`任务已存在,忽略重复添加: ${app.pkgname} (${app.origin})`); - return; - } - - const arch = window.apm_store.arch || "amd64"; - const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`; - const downloadId = getNextDownloadId(); - - const download: DownloadItem = { - id: downloadId, - name: app.name, - pkgname: app.pkgname, - version: app.version, - icon: `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/icon.png`, - status: "queued", - progress: 0, - downloadedSize: 0, - totalSize: 0, - speed: 0, - timeRemaining: 0, - startTime: Date.now(), - logs: [{ time: Date.now(), message: "开始更新..." }], - source: "APM Update", - retry: false, - upgradeOnly: true, - origin: app.origin, - }; - - downloads.value.push(download); - window.ipcRenderer.send("queue-install", JSON.stringify(download)); -}; - -export const handleRemove = (appObj?: App) => { - const targetApp = appObj || currentApp.value; - if (!targetApp?.pkgname) return; - window.ipcRenderer.send("remove-installed", { - pkgname: targetApp.pkgname, - origin: targetApp.origin, - }); -}; - window.ipcRenderer.on("remove-complete", (_event, log: DownloadResult) => { if (log.success) { if (log.origin === "spark") { @@ -221,7 +168,9 @@ window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => { if (downloadObj) { // 更新中心来源的任务用「更新」语义,其余(普通安装)用「安装」语义, // 避免把安装结果误报为「下载完成」。 - const isUpdate = (downloadObj.source ?? "").toLowerCase().includes("update"); + const isUpdate = (downloadObj.source ?? "") + .toLowerCase() + .includes("update"); const doneLabel = isUpdate ? "更新完成" : "安装完成"; const failVerb = isUpdate ? "更新失败" : "安装失败"; @@ -242,12 +191,13 @@ window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => { time: Date.now(), message: `${failVerb}: ${reason}`, }); + + // 兜底:主进程检测到未安装 APM 时会直接失败并转发 trigger-apm-install-dialog。 + // 渲染端这里再识别一次该失败原因,确保「未安装 APM」永远不会静默失败—— + // 即使主进程事件在弹窗挂载前丢失,也能就地弹出 APM 安装引导。 + if (downloadObj.origin === "apm" && reason.includes("未安装 APM")) { + showApmInstallDialog.value = true; + } } } }); - -window.ipcRenderer.on("queue-install", (_event, payload: unknown) => { - const serializedPayload = - typeof payload === "string" ? payload : JSON.stringify(payload); - window.ipcRenderer.send("queue-install", serializedPayload); -});