refactor(install): 重构下载安装队列逻辑,拆分并发控制

1.  将下载与安装流程分离,实现并发下载限制与单安装实例控制
2.  新增任务阶段枚举,替换原有activeDownloads计数逻辑
3.  优化取消任务的处理流程,区分运行中和排队中的任务
4.  修复下载队列显示的统计错误,改为展示已完成任务数
5.  调整日志格式与代码注释,提升可读性
This commit is contained in:
2026-07-13 23:06:05 +08:00
parent 6328f178d3
commit b233fea4d5
2 changed files with 149 additions and 86 deletions
+109 -44
View File
@@ -43,6 +43,7 @@ type InstallTask = {
filename?: string;
origin: "spark" | "apm";
cancelled?: boolean;
phase: "queued-download" | "downloading" | "queued-install" | "installing";
};
const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh";
@@ -78,7 +79,9 @@ const resolveDesktopDir = async (): Promise<string> => {
}
} catch {
// user-dirs.dirs无法读取
logger.warn(`Failed to get XDG_DESKTOP_DIR, I'm falling back to ~/Desktop!!`);
logger.warn(
`Failed to get XDG_DESKTOP_DIR, I'm falling back to ~/Desktop!!`,
);
}
return path.join(os.homedir(), "Desktop");
};
@@ -147,9 +150,7 @@ const createApmDesktopShortcut = async (
logger.info(`Wrote shortcut ${destPath} for ${pkgname}.`);
return;
} catch (err) {
logger.warn(
`Failed to create desktop shortcut for ${pkgname}: ${err}`,
);
logger.warn(`Failed to create desktop shortcut for ${pkgname}: ${err}`);
return;
}
}
@@ -160,7 +161,10 @@ const createApmDesktopShortcut = async (
export const tasks = new Map<number, InstallTask>();
let idle = true; // Indicates if the installation manager is idle
// 下载与安装分离:最多 5 个并发下载,安装一次只允许一个
const MAX_CONCURRENT_DOWNLOADS = 5;
let activeDownloadCount = 0;
let installIdle = true;
export const checkSuperUserCommand = async (): Promise<string> => {
if (process.getuid?.() === 0) return "";
@@ -372,19 +376,19 @@ ipcMain.on("queue-install", async (event, download_json) => {
metalinkUrl,
filename,
origin: origin || "apm",
phase: metalinkUrl ? "queued-download" : "queued-install",
};
tasks.set(id, task);
if (idle) processNextInQueue();
processNextDownload();
processNextInstall();
});
// Cancel Handler
ipcMain.on("cancel-install", (event, id) => {
if (tasks.has(id)) {
const task = tasks.get(id);
if (task) {
if (!task) return;
task.cancelled = true;
task.download_process?.kill();
task.install_process?.kill();
logger.info(`已取消任务: ${id}`);
// 删除下载目录
@@ -397,7 +401,7 @@ ipcMain.on("cancel-install", (event, id) => {
}
}
// 主动发送完成(失败)事件close 回调会因 cancelled 标志跳过
// 主动发送完成(失败)事件
task.webContents?.send("install-complete", {
id,
success: false,
@@ -410,34 +414,57 @@ ipcMain.on("cancel-install", (event, id) => {
}),
});
const isRunning = task.phase === "downloading" || task.phase === "installing";
if (isRunning) {
// 运行中的任务:终止进程,由对应的阶段处理器在 finally 中清理计数器与队列
task.download_process?.kill();
task.install_process?.kill();
} else {
// 排队中的任务(未开始执行):直接清理并调度
tasks.delete(id);
idle = true;
if (tasks.size > 0) processNextInQueue();
}
processNextDownload();
processNextInstall();
}
});
async function processNextInQueue() {
if (!idle) return;
/**
* 尝试启动排队中的下载任务,最多同时运行 MAX_CONCURRENT_DOWNLOADS 个。
*/
function processNextDownload() {
while (activeDownloadCount < MAX_CONCURRENT_DOWNLOADS) {
const task = Array.from(tasks.values()).find(
(t) => t.phase === "queued-download" && !t.cancelled,
);
if (!task) break;
task.phase = "downloading";
activeDownloadCount++;
void runDownloadPhase(task);
}
}
// Always take the first task to ensure sequence
const task = Array.from(tasks.values())[0];
/**
* 尝试启动排队中的安装任务,安装一次只允许一个。
*/
function processNextInstall() {
if (!installIdle) return;
const task = Array.from(tasks.values()).find(
(t) => t.phase === "queued-install" && !t.cancelled,
);
if (!task) {
idle = true;
installIdle = true;
return;
}
installIdle = false;
task.phase = "installing";
void runInstallPhase(task);
}
// 如果任务已被取消,跳过并处理下一个
if (task.cancelled) {
tasks.delete(task.id);
idle = true;
if (tasks.size > 0) {
processNextInQueue();
}
return;
}
idle = false;
/**
* 下载阶段:获取 Metalink → aria2c 下载(含重试)。
* 下载完成后任务进入 queued-install 等待安装。
*/
async function runDownloadPhase(task: InstallTask) {
const { webContents, id, downloadDir } = task;
const sendLog = (msg: string) => {
@@ -452,6 +479,8 @@ async function processNextInQueue() {
};
try {
if (task.cancelled) throw new Error("下载已取消");
// 1. Metalink & Aria2c Phase
if (task.metalinkUrl) {
try {
@@ -617,10 +646,50 @@ async function processNextInQueue() {
}
}
// 进入安装阶段前检查是否已取消
if (task.cancelled) {
throw new Error("安装已取消");
// 下载完成,进入安装队列等待
task.phase = "queued-install";
} catch (error) {
logger.error(`Task ${id} download failed: ${error}`);
if (!task.cancelled) {
webContents?.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: error instanceof Error ? error.message : String(error),
stdout: "",
stderr: "",
}),
});
}
tasks.delete(id);
} finally {
activeDownloadCount--;
processNextDownload();
processNextInstall();
}
}
/**
* 安装阶段:执行安装命令,安装一次只允许一个。
*/
async function runInstallPhase(task: InstallTask) {
const { webContents, id } = task;
const sendLog = (msg: string) => {
webContents?.send("install-log", { id, time: Date.now(), message: msg });
};
const sendStatus = (status: string) => {
webContents?.send("install-status", {
id,
time: Date.now(),
message: status,
});
};
try {
if (task.cancelled) throw new Error("安装已取消");
// 2. Install Phase
sendStatus("installing");
@@ -706,9 +775,7 @@ async function processNextInQueue() {
try {
await createApmDesktopShortcut(task.pkgname, sendLog);
} catch (err) {
logger.warn(
`Failed to create shortcut for ${task.pkgname}: ${err}`,
);
logger.warn(`Failed to create shortcut for ${task.pkgname}: ${err}`);
}
}
} else {
@@ -723,7 +790,8 @@ async function processNextInQueue() {
message: JSON.stringify(msgObj),
});
} catch (error) {
logger.error(`Task ${id} failed: ${error}`);
logger.error(`Task ${id} install failed: ${error}`);
if (!task.cancelled) {
webContents?.send("install-complete", {
id,
success: false,
@@ -735,15 +803,12 @@ async function processNextInQueue() {
stderr: "",
}),
});
}
} finally {
// 如果已被 cancel handler 清理,跳过重复清理
if (!task.cancelled) {
tasks.delete(id);
idle = true;
if (tasks.size > 0) {
processNextInQueue();
}
}
installIdle = true;
processNextInstall();
processNextDownload();
}
}
+3 -5
View File
@@ -15,7 +15,7 @@
v-if="downloads.length"
class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500 dark:bg-slate-800/70 dark:text-slate-300"
>
({{ activeDownloads }}/{{ downloads.length }})
({{ completedDownloads }}/{{ downloads.length }})
</span>
</div>
<div class="flex items-center gap-2">
@@ -148,10 +148,8 @@ const emit = defineEmits<{
const isExpanded = ref(false);
const activeDownloads = computed(() => {
return props.downloads.filter(
(d) => d.status === "downloading" || d.status === "installing",
).length;
const completedDownloads = computed(() => {
return props.downloads.filter((d) => d.status === "completed").length;
});
const toggleExpand = () => {