diff --git a/docs/app-list-cache-analysis.md b/docs/app-list-cache-analysis.md index f027bb9b..dab25c41 100644 --- a/docs/app-list-cache-analysis.md +++ b/docs/app-list-cache-analysis.md @@ -236,3 +236,33 @@ ### 8.4 验证边界 本机 headless 无法真实触发 aria2c 下载与 GUI 日志面板;已通过 `vue-tsc` 类型检查与 `read_lints` 0 错误。真实失败场景(如 Metalink 404、网络中断)下的日志连贯性需用户在真机验证。 + +--- + +## 9. 附加修复:更新中心点击"更新"后卡"开始更新..." + +### 9.1 现象(用户反馈) +在软件更新中心选择 Visual Studio Code: 点击更新后,下载详情弹窗日志停在: +``` +[09:23:41] 开始更新... +``` +状态为 `queued`,后续无进展、无错误,界面卡死。 + +### 9.2 根因 +更新中心 `electron/main/backend/update-center/service.ts` 的 `start()` 方法,把任务通过 `webContents.send("queue-install", JSON.stringify(installTaskData))` 发送给主下载队列。 + +**但 `webContents.send` 是从主进程向渲染进程发消息,渲染进程的 `ipcRenderer.on("queue-install")` 能收到,而主进程的 `ipcMain.on("queue-install") 监听的是渲染进程 `ipcRenderer.send` 的消息,监听不到自己 `webContents.send` 的消息。** + +结果:任务根本没有进入 `install-manager.ts` 的下载队列,`processNextDownload()` 永远不会执行,UI 自然卡在"开始更新...」。 + +### 9.3 修复(已提交) +- **I1(抽离可复用的入队函数)**:在 `electron/main/backend/install-manager.ts` 新增导出 `addInstallTask(payload, sender)`,把原来 `ipcMain.on("queue-install")` 里的解析、校验、去重、APM 检查、命令构建、入队逻辑全部抽到该函数。`ipcMain.on` 本身只做 JSON 解析并调用 `addInstallTask`。 +- **I2(更新中心直接调用入队)**:`electron/main/backend/update-center/service.ts` 的 `start()` 不再 `webContents.send("queue-install")`,而是直接 `await addInstallTask(installTaskData, webContents)`,使任务真正进入主下载队列。 +- **I3(类型安全)**:新增 `QueueInstallPayload` 接口,避免 `any`。 + +改动文件: +- `electron/main/backend/install-manager.ts`(I1、I3) +- `electron/main/backend/update-center/service.ts`(I2) + +### 9.4 验证边界 +本机 headless 无法启动 GUI 触发真实更新下载;已通过 `vue-tsc` 与 `read_lints` 0 错误。真机需在软件更新中心勾选一项更新并点击"更新",确认日志从"开始更新..."推进到"正在获取 Metalink 文件"、下载进度增加,最终进入安装或明确失败。 diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index 8100cd62..d10b32a8 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -50,6 +50,16 @@ const isOriginEnabled = ( return storeFilter === "both" || storeFilter === origin; }; +export interface QueueInstallPayload { + id: number; + pkgname: string; + metalinkUrl?: string; + filename?: string; + origin?: "spark" | "apm"; + upgradeOnly?: boolean; + retry?: boolean; +} + type InstallTask = { id: number; pkgname: string; @@ -283,34 +293,30 @@ const parseUpgradableList = (output: string) => { return apps; }; -// Listen for download requests from renderer process -ipcMain.on("queue-install", async (event, download_json) => { - let download: unknown; - try { - download = - typeof download_json === "string" - ? JSON.parse(download_json) - : download_json; - } catch (err) { - logger.error({ err }, "queue-install: invalid JSON payload, ignoring task"); - return; - } - const { id, pkgname, metalinkUrl, filename, origin, upgradeOnly } = - download || {}; +/** + * 将任务加入安装/下载队列。被 IPC 监听与更新中心等内部模块共享使用。 + * 入队前会执行包名、文件名、metalinkUrl 的校验,并检查重复任务。 + * @returns 是否成功入队 + */ +export const addInstallTask = async ( + download: QueueInstallPayload, + sender: WebContents | null, +): Promise => { + const { id, pkgname, metalinkUrl, filename, origin, upgradeOnly } = download; if (!id || !pkgname) { - logger.warn("passed arguments missing id or pkgname"); - return; + logger.warn("addInstallTask: passed arguments missing id or pkgname"); + return false; } // 包名/文件名白名单校验:防止路径遍历(如 ../../)或非法字符进入下载目录与安装命令构建 if (!PKGNAME_PATTERN.test(pkgname)) { - logger.warn(`queue-install invalid pkgname: ${pkgname}`); - return; + logger.warn(`addInstallTask invalid pkgname: ${pkgname}`); + return false; } if (filename && !PKGNAME_PATTERN.test(filename)) { - logger.warn(`queue-install invalid filename: ${filename}`); - return; + logger.warn(`addInstallTask invalid filename: ${filename}`); + return false; } // metalinkUrl 来自渲染端(由目录 filename / 主进程解析的 downloadUrl 拼成)。 @@ -322,14 +328,14 @@ ipcMain.on("queue-install", async (event, download_json) => { "https://erotica.spark-app.store", ); if (!isRelative && !isOfficial) { - logger.warn(`queue-install invalid metalinkUrl: ${metalinkUrl}`); - return; + logger.warn(`addInstallTask invalid metalinkUrl: ${metalinkUrl}`); + return false; } } logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`); - const webContents = event.sender; + const webContents = sender; // 避免重复添加同一任务(检查 pkgname + origin),但允许重试下载 if (!download.retry) { @@ -337,12 +343,12 @@ ipcMain.on("queue-install", async (event, download_json) => { (t) => t.pkgname === pkgname && t.origin === origin, ); if (existingTask) { - webContents.send("install-log", { + webContents?.send("install-log", { id, time: Date.now(), message: `任务 ${pkgname} (${origin}) 已在列表中,忽略重复添加`, }); - webContents.send("install-complete", { + webContents?.send("install-complete", { id, success: false, time: Date.now(), @@ -353,12 +359,12 @@ ipcMain.on("queue-install", async (event, download_json) => { stderr: "", }), }); - return; + return false; } } const superUserCmd = await checkSuperUserCommand(); let execCommand = ""; - const execParams = []; + const execParams: string[] = []; const downloadDir = path.join( os.tmpdir(), `spark-store-${process.pid}`, @@ -370,8 +376,8 @@ ipcMain.on("queue-install", async (event, download_json) => { if (origin === "apm") { const hasApm = await checkApmAvailable(); if (!hasApm) { - webContents.send("trigger-apm-install-dialog"); - webContents.send("install-complete", { + webContents?.send("trigger-apm-install-dialog"); + webContents?.send("install-complete", { id, success: false, time: Date.now(), @@ -382,7 +388,7 @@ ipcMain.on("queue-install", async (event, download_json) => { stderr: "", }), }); - return; + return false; } } @@ -420,7 +426,7 @@ ipcMain.on("queue-install", async (event, download_json) => { const safeFilename = path.basename(filename); if (safeFilename !== filename) { logger.warn(`ssinstall filename contains path traversal: ${filename}`); - return; + return false; } execParams.push("ssinstall", path.join(downloadDir, safeFilename)); } else { @@ -446,6 +452,23 @@ ipcMain.on("queue-install", async (event, download_json) => { tasks.set(id, task); processNextDownload(); processNextInstall(); + return true; +}; + +// Listen for download requests from renderer process +ipcMain.on("queue-install", async (event, download_json) => { + let download: unknown; + try { + download = + typeof download_json === "string" + ? JSON.parse(download_json) + : download_json; + } catch (err) { + logger.error({ err }, "queue-install: invalid JSON payload, ignoring task"); + return; + } + + await addInstallTask(download as QueueInstallPayload, event.sender); }); // Cancel Handler diff --git a/electron/main/backend/update-center/service.ts b/electron/main/backend/update-center/service.ts index a05a6069..3a29b921 100644 --- a/electron/main/backend/update-center/service.ts +++ b/electron/main/backend/update-center/service.ts @@ -1,4 +1,8 @@ import { BrowserWindow } from "electron"; +import { + addInstallTask, + type QueueInstallPayload, +} from "../install-manager"; import { IGNORE_CONFIG_PATH, applyIgnoredEntries, @@ -249,8 +253,9 @@ export const createUpdateCenterService = ( ? `${item.downloadUrl}.metalink` : undefined; - // 发送到主下载队列 - const installTaskData = { + // 直接加入主下载队列(之前用 webContents.send("queue-install") 只会发给渲染端, + // 主进程 ipcMain 监听不到自己发出的 send,导致任务实际未启动而卡死)。 + const installTaskData: QueueInstallPayload = { id: updateTaskId, pkgname: item.pkgname, metalinkUrl, @@ -260,8 +265,7 @@ export const createUpdateCenterService = ( retry: false, }; - // 通过 IPC 发送到主下载队列 - webContents.send("queue-install", JSON.stringify(installTaskData)); + await addInstallTask(installTaskData, webContents); // 从更新中心的 items 中移除该应用(不再显示在更新列表中) currentItems = currentItems.filter(