fix(update-center): 修复更新中心点击更新后卡在"开始更新..."不动

- 根因:service.ts 用 webContents.send("queue-install") 向主下载队列发任务,
  但 webContents.send 的目标对象是渲染进程,主进程 ipcMain 监听不到自己发出的消息,
  导致任务从未进入 install-manager 的下载队列,UI 永远卡在 queued。
- 将 install-manager.ts 的 queue-install 处理逻辑抽为导出的 addInstallTask(payload, sender),
  新增 QueueInstallPayload 类型,避免 any。
- 更新中心 service.ts 的 start() 改为直接 await addInstallTask(...),任务真正入队。
- 文档 docs/app-list-cache-analysis.md 新增第 9 节记录本修复。
This commit is contained in:
xiyidaiwa
2026-08-13 09:37:17 +08:00
parent d7bdcf3680
commit b5c5ab04b5
3 changed files with 92 additions and 35 deletions
+30
View File
@@ -236,3 +236,33 @@
### 8.4 验证边界 ### 8.4 验证边界
本机 headless 无法真实触发 aria2c 下载与 GUI 日志面板;已通过 `vue-tsc` 类型检查与 `read_lints` 0 错误。真实失败场景(如 Metalink 404、网络中断)下的日志连贯性需用户在真机验证。 本机 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 文件"、下载进度增加,最终进入安装或明确失败。
+54 -31
View File
@@ -50,6 +50,16 @@ const isOriginEnabled = (
return storeFilter === "both" || storeFilter === origin; 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 = { type InstallTask = {
id: number; id: number;
pkgname: string; pkgname: string;
@@ -283,34 +293,30 @@ const parseUpgradableList = (output: string) => {
return apps; return apps;
}; };
// Listen for download requests from renderer process /**
ipcMain.on("queue-install", async (event, download_json) => { * 将任务加入安装/下载队列。被 IPC 监听与更新中心等内部模块共享使用。
let download: unknown; * 入队前会执行包名、文件名、metalinkUrl 的校验,并检查重复任务。
try { * @returns 是否成功入队
download = */
typeof download_json === "string" export const addInstallTask = async (
? JSON.parse(download_json) download: QueueInstallPayload,
: download_json; sender: WebContents | null,
} catch (err) { ): Promise<boolean> => {
logger.error({ err }, "queue-install: invalid JSON payload, ignoring task"); const { id, pkgname, metalinkUrl, filename, origin, upgradeOnly } = download;
return;
}
const { id, pkgname, metalinkUrl, filename, origin, upgradeOnly } =
download || {};
if (!id || !pkgname) { if (!id || !pkgname) {
logger.warn("passed arguments missing id or pkgname"); logger.warn("addInstallTask: passed arguments missing id or pkgname");
return; return false;
} }
// 包名/文件名白名单校验:防止路径遍历(如 ../../)或非法字符进入下载目录与安装命令构建 // 包名/文件名白名单校验:防止路径遍历(如 ../../)或非法字符进入下载目录与安装命令构建
if (!PKGNAME_PATTERN.test(pkgname)) { if (!PKGNAME_PATTERN.test(pkgname)) {
logger.warn(`queue-install invalid pkgname: ${pkgname}`); logger.warn(`addInstallTask invalid pkgname: ${pkgname}`);
return; return false;
} }
if (filename && !PKGNAME_PATTERN.test(filename)) { if (filename && !PKGNAME_PATTERN.test(filename)) {
logger.warn(`queue-install invalid filename: ${filename}`); logger.warn(`addInstallTask invalid filename: ${filename}`);
return; return false;
} }
// metalinkUrl 来自渲染端(由目录 filename / 主进程解析的 downloadUrl 拼成)。 // metalinkUrl 来自渲染端(由目录 filename / 主进程解析的 downloadUrl 拼成)。
@@ -322,14 +328,14 @@ ipcMain.on("queue-install", async (event, download_json) => {
"https://erotica.spark-app.store", "https://erotica.spark-app.store",
); );
if (!isRelative && !isOfficial) { if (!isRelative && !isOfficial) {
logger.warn(`queue-install invalid metalinkUrl: ${metalinkUrl}`); logger.warn(`addInstallTask invalid metalinkUrl: ${metalinkUrl}`);
return; return false;
} }
} }
logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`); logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`);
const webContents = event.sender; const webContents = sender;
// 避免重复添加同一任务(检查 pkgname + origin),但允许重试下载 // 避免重复添加同一任务(检查 pkgname + origin),但允许重试下载
if (!download.retry) { if (!download.retry) {
@@ -337,12 +343,12 @@ ipcMain.on("queue-install", async (event, download_json) => {
(t) => t.pkgname === pkgname && t.origin === origin, (t) => t.pkgname === pkgname && t.origin === origin,
); );
if (existingTask) { if (existingTask) {
webContents.send("install-log", { webContents?.send("install-log", {
id, id,
time: Date.now(), time: Date.now(),
message: `任务 ${pkgname} (${origin}) 已在列表中,忽略重复添加`, message: `任务 ${pkgname} (${origin}) 已在列表中,忽略重复添加`,
}); });
webContents.send("install-complete", { webContents?.send("install-complete", {
id, id,
success: false, success: false,
time: Date.now(), time: Date.now(),
@@ -353,12 +359,12 @@ ipcMain.on("queue-install", async (event, download_json) => {
stderr: "", stderr: "",
}), }),
}); });
return; return false;
} }
} }
const superUserCmd = await checkSuperUserCommand(); const superUserCmd = await checkSuperUserCommand();
let execCommand = ""; let execCommand = "";
const execParams = []; const execParams: string[] = [];
const downloadDir = path.join( const downloadDir = path.join(
os.tmpdir(), os.tmpdir(),
`spark-store-${process.pid}`, `spark-store-${process.pid}`,
@@ -370,8 +376,8 @@ ipcMain.on("queue-install", async (event, download_json) => {
if (origin === "apm") { if (origin === "apm") {
const hasApm = await checkApmAvailable(); const hasApm = await checkApmAvailable();
if (!hasApm) { if (!hasApm) {
webContents.send("trigger-apm-install-dialog"); webContents?.send("trigger-apm-install-dialog");
webContents.send("install-complete", { webContents?.send("install-complete", {
id, id,
success: false, success: false,
time: Date.now(), time: Date.now(),
@@ -382,7 +388,7 @@ ipcMain.on("queue-install", async (event, download_json) => {
stderr: "", stderr: "",
}), }),
}); });
return; return false;
} }
} }
@@ -420,7 +426,7 @@ ipcMain.on("queue-install", async (event, download_json) => {
const safeFilename = path.basename(filename); const safeFilename = path.basename(filename);
if (safeFilename !== filename) { if (safeFilename !== filename) {
logger.warn(`ssinstall filename contains path traversal: ${filename}`); logger.warn(`ssinstall filename contains path traversal: ${filename}`);
return; return false;
} }
execParams.push("ssinstall", path.join(downloadDir, safeFilename)); execParams.push("ssinstall", path.join(downloadDir, safeFilename));
} else { } else {
@@ -446,6 +452,23 @@ ipcMain.on("queue-install", async (event, download_json) => {
tasks.set(id, task); tasks.set(id, task);
processNextDownload(); processNextDownload();
processNextInstall(); 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 // Cancel Handler
@@ -1,4 +1,8 @@
import { BrowserWindow } from "electron"; import { BrowserWindow } from "electron";
import {
addInstallTask,
type QueueInstallPayload,
} from "../install-manager";
import { import {
IGNORE_CONFIG_PATH, IGNORE_CONFIG_PATH,
applyIgnoredEntries, applyIgnoredEntries,
@@ -249,8 +253,9 @@ export const createUpdateCenterService = (
? `${item.downloadUrl}.metalink` ? `${item.downloadUrl}.metalink`
: undefined; : undefined;
// 发送到主下载队列 // 直接加入主下载队列(之前用 webContents.send("queue-install") 只会发给渲染端,
const installTaskData = { // 主进程 ipcMain 监听不到自己发出的 send,导致任务实际未启动而卡死)。
const installTaskData: QueueInstallPayload = {
id: updateTaskId, id: updateTaskId,
pkgname: item.pkgname, pkgname: item.pkgname,
metalinkUrl, metalinkUrl,
@@ -260,8 +265,7 @@ export const createUpdateCenterService = (
retry: false, retry: false,
}; };
// 通过 IPC 发送到主下载队列 await addInstallTask(installTaskData, webContents);
webContents.send("queue-install", JSON.stringify(installTaskData));
// 从更新中心的 items 中移除该应用(不再显示在更新列表中) // 从更新中心的 items 中移除该应用(不再显示在更新列表中)
currentItems = currentItems.filter( currentItems = currentItems.filter(