复用下载中心(1/2)

This commit is contained in:
2026-04-12 17:53:16 +08:00
parent 6fcfa438d9
commit e2f59b3cdf
9 changed files with 532 additions and 306 deletions

View File

@@ -1,7 +1,5 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { spawn } from "node:child_process";
import { downloadPackage, type DownloadResult } from "../shared-installer";
import type { UpdateCenterItem } from "./types";
export interface Aria2DownloadResult {
@@ -16,8 +14,6 @@ export interface RunAria2DownloadOptions {
signal?: AbortSignal;
}
const PROGRESS_PATTERN = /(\d{1,3}(?:\.\d+)?)%/;
export const runAria2Download = async ({
item,
downloadDir,
@@ -29,68 +25,18 @@ export const runAria2Download = async ({
throw new Error(`Missing download metadata for ${item.pkgname}`);
}
await mkdir(downloadDir, { recursive: true });
const filePath = join(downloadDir, item.fileName);
// Use .metalink URL for download (same as Qt version)
// 使用与商店安装相同的下载逻辑
const metalinkUrl = `${item.downloadUrl}.metalink`;
await new Promise<void>((resolve, reject) => {
const child = spawn("aria2c", [
"--dir",
downloadDir,
"--out",
item.fileName,
"--enable-rpc=false",
"--console-log-level=warn",
"--summary-interval=1",
"--allow-overwrite=true",
"--connect-timeout=30",
"--max-tries=3",
metalinkUrl,
]);
const abortDownload = () => {
child.kill();
reject(new Error(`Update task cancelled: ${item.pkgname}`));
};
if (signal?.aborted) {
abortDownload();
return;
}
signal?.addEventListener("abort", abortDownload, { once: true });
const handleOutput = (chunk: Buffer) => {
const message = chunk.toString().trim();
if (!message) {
return;
}
onLog?.(message);
const progressMatch = message.match(PROGRESS_PATTERN);
if (progressMatch) {
onProgress?.(Number(progressMatch[1]));
}
};
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
child.on("error", reject);
child.on("close", (code) => {
signal?.removeEventListener("abort", abortDownload);
if (code === 0) {
resolve();
return;
}
reject(new Error(`aria2c exited with code ${code ?? -1}`));
});
const result = await downloadPackage({
pkgname: item.pkgname,
metalinkUrl,
filename: item.fileName,
downloadDir,
onLog,
onProgress,
signal,
});
onProgress?.(100);
return { filePath };
return { filePath: result.filePath };
};

View File

@@ -155,20 +155,30 @@ const loadAptssItemMetadata = async (
| { item: UpdateCenterItem; warning?: undefined }
| { item: null; warning: string }
> => {
console.log(`[DEBUG] Loading APTSS metadata for ${item.pkgname}`);
const printUrisCommand = getAptssPrintUrisCommand(item.pkgname);
console.log(`[DEBUG] APTSS command: ${printUrisCommand.command} ${printUrisCommand.args.join(' ')}`);
const metadataResult = await runCommand(
printUrisCommand.command,
printUrisCommand.args,
);
console.log(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`);
console.log(`[DEBUG] APTSS metadata stdout: ${metadataResult.stdout.substring(0, 500)}`);
console.log(`[DEBUG] APTSS metadata stderr: ${metadataResult.stderr.substring(0, 500)}`);
const commandError = getCommandError(
`aptss metadata query for ${item.pkgname}`,
metadataResult,
);
if (commandError) {
console.log(`[DEBUG] APTSS metadata error: ${commandError}`);
return { item: null, warning: commandError };
}
const metadata = parsePrintUrisOutput(metadataResult.stdout);
console.log(`[DEBUG] APTSS parsed metadata:`, metadata);
if (!metadata) {
return {
item: null,
@@ -424,14 +434,8 @@ export const initializeUpdateCenter = (): UpdateCenterService => {
return updateCenterService;
}
const superUserCmdProvider = async (): Promise<string> => {
const installManager = await import("../install-manager.js");
return installManager.checkSuperUserCommand();
};
updateCenterService = createUpdateCenterService({
loadItems: loadUpdateCenterItems,
superUserCmdProvider,
});
registerUpdateCenterIpc(ipcMain, updateCenterService);

View File

@@ -1,19 +1,12 @@
import { spawn } from "node:child_process";
import { join } from "node:path";
import { runAria2Download, type Aria2DownloadResult } from "./download";
import { installPackage } from "../shared-installer";
import type { UpdateCenterQueue, UpdateCenterTask } from "./queue";
import type { UpdateCenterItem } from "./types";
const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh";
const SSINSTALL_PATH = "/usr/bin/ssinstall";
const DEFAULT_DOWNLOAD_ROOT = "/tmp/spark-store/update-center";
export interface UpdateCommand {
execCommand: string;
execParams: string[];
}
export interface InstallUpdateItemOptions {
item: UpdateCenterItem;
filePath?: string;
@@ -55,73 +48,10 @@ export interface CreateTaskRunnerOptions extends TaskRunnerDependencies {
superUserCmd?: string;
}
const runCommand = async (
execCommand: string,
execParams: string[],
onLog?: (message: string) => void,
signal?: AbortSignal,
): Promise<void> => {
await new Promise<void>((resolve, reject) => {
const child = spawn(execCommand, execParams, {
shell: false,
env: process.env,
});
const handleOutput = (chunk: Buffer) => {
const message = chunk.toString().trim();
if (message) {
onLog?.(message);
}
};
const abortCommand = () => {
child.kill();
reject(new Error(`Update task cancelled: ${execParams.join(" ")}`));
};
if (signal?.aborted) {
abortCommand();
return;
}
signal?.addEventListener("abort", abortCommand, { once: true });
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
child.on("error", reject);
child.on("close", (code) => {
signal?.removeEventListener("abort", abortCommand);
if (code === 0) {
resolve();
return;
}
reject(new Error(`${execCommand} exited with code ${code ?? -1}`));
});
});
};
const buildPrivilegedCommand = (
command: string,
args: string[],
superUserCmd?: string,
): UpdateCommand => {
if (superUserCmd) {
return {
execCommand: superUserCmd,
execParams: [command, ...args],
};
}
return {
execCommand: command,
execParams: args,
};
};
// Removed buildLegacySparkUpgradeCommand - all updates now require downloading the deb package first
// to avoid aptss install popup. Use ssinstall with downloaded deb file instead.
/**
* 安装更新项
* 使用与商店安装相同的逻辑
*/
export const installUpdateItem = async ({
item,
filePath,
@@ -135,33 +65,17 @@ export const installUpdateItem = async ({
);
}
if (item.source === "apm") {
const installCommand = buildPrivilegedCommand(
SHELL_CALLER_PATH,
["apm", "ssinstall", filePath],
superUserCmd,
);
await runCommand(
installCommand.execCommand,
installCommand.execParams,
onLog,
signal,
);
return;
}
// APTSS (Spark Store) packages use ssinstall
const installCommand = buildPrivilegedCommand(
SSINSTALL_PATH,
[filePath, "--delete-after-install", "--no-create-desktop-entry", "--native"],
// 使用与商店安装相同的安装逻辑
const origin = item.source === "apm" ? "apm" : "spark";
await installPackage({
pkgname: item.pkgname,
filePath,
origin,
superUserCmd,
);
await runCommand(
installCommand.execCommand,
installCommand.execParams,
onLog,
signal,
);
});
};
export const createTaskRunner = (
@@ -194,11 +108,7 @@ export const createTaskRunner = (
return {
cancelActiveTask: () => {
if (!activeAbortController || activeAbortController.signal.aborted) {
return;
}
activeAbortController.abort();
activeAbortController?.abort();
},
runNextTask: async () => {
if (inFlightTask) {

View File

@@ -270,7 +270,9 @@ export const parsePrintUrisOutput = (
return null;
}
const [, downloadUrl, fileName, size, sha512] = match;
const [, rawDownloadUrl, fileName, size, sha512] = match;
// Clean up the URL: remove backticks and extra spaces
const downloadUrl = rawDownloadUrl.replace(/[`'"]/g, "").trim();
return {
downloadUrl,
fileName,

View File

@@ -88,8 +88,8 @@ export const createUpdateCenterQueue = (): UpdateCenterQueue => {
let refreshing = false;
let nextTaskId = 1;
const getTask = (taskId: number): UpdateCenterTask | undefined =>
tasks.find((task) => task.id === taskId);
const getTaskIndex = (taskId: number): number =>
tasks.findIndex((task) => task.id === taskId);
return {
setItems: (nextItems) => {
@@ -117,40 +117,59 @@ export const createUpdateCenterQueue = (): UpdateCenterQueue => {
return task;
},
markActiveTask: (taskId, status) => {
const task = getTask(taskId);
if (!task) {
const taskIndex = getTaskIndex(taskId);
if (taskIndex === -1) {
return;
}
task.status = status;
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
tasks = tasks.map((task, index) =>
index === taskIndex ? { ...task, status } : task,
);
},
updateTaskProgress: (taskId, progress) => {
const task = getTask(taskId);
if (!task) {
const taskIndex = getTaskIndex(taskId);
if (taskIndex === -1) {
return;
}
task.progress = clampProgress(progress);
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
tasks = tasks.map((task, index) =>
index === taskIndex
? { ...task, progress: clampProgress(progress) }
: task,
);
},
appendTaskLog: (taskId, message, time = Date.now()) => {
const task = getTask(taskId);
if (!task) {
const taskIndex = getTaskIndex(taskId);
if (taskIndex === -1) {
return;
}
task.logs = [...task.logs, { time, message }];
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
tasks = tasks.map((task, index) =>
index === taskIndex
? { ...task, logs: [...task.logs, { time, message }] }
: task,
);
},
finishTask: (taskId, status, error) => {
const task = getTask(taskId);
if (!task) {
const taskIndex = getTaskIndex(taskId);
if (taskIndex === -1) {
return;
}
task.status = status;
task.error = error;
if (status === "completed") {
task.progress = 100;
}
// 创建新的 task 对象和新的 tasks 数组以触发状态更新
tasks = tasks.map((task, index) =>
index === taskIndex
? {
...task,
status,
error,
progress: status === "completed" ? 100 : task.progress,
}
: task,
);
},
getNextQueuedTask: () => tasks.find((task) => task.status === "queued"),
getSnapshot: () => createSnapshot(items, tasks, warnings, refreshing),

View File

@@ -1,3 +1,4 @@
import { BrowserWindow, ipcMain } from "electron";
import {
LEGACY_IGNORE_CONFIG_PATH,
applyIgnoredEntries,
@@ -5,7 +6,6 @@ import {
loadIgnoredEntries,
saveIgnoredEntries,
} from "./ignore-config";
import { createTaskRunner, type UpdateCenterTaskRunner } from "./install";
import {
createUpdateCenterQueue,
type UpdateCenterQueue,
@@ -79,11 +79,6 @@ export interface CreateUpdateCenterServiceOptions {
loadItems: () => Promise<UpdateCenterItem[] | UpdateCenterLoadedItems>;
loadIgnoredEntries?: () => Promise<Set<string>>;
saveIgnoredEntries?: (entries: ReadonlySet<string>) => Promise<void>;
createTaskRunner?: (
queue: UpdateCenterQueue,
superUserCmd?: string,
) => UpdateCenterTaskRunner;
superUserCmdProvider?: () => Promise<string>;
}
const getTaskKey = (
@@ -112,19 +107,9 @@ const toState = (
migrationTarget: item.migrationTarget,
aptssVersion: item.aptssVersion,
})),
tasks: snapshot.tasks.map((task) => ({
taskKey: getTaskKey(task.item),
packageName: task.pkgname,
source: task.item.source,
localIcon: task.item.localIcon,
remoteIcon: task.item.remoteIcon,
status: task.status,
progress: task.progress,
logs: task.logs.map((log) => ({ ...log })),
errorMessage: task.error ?? "",
})),
tasks: [], // 不再展示任务日志
warnings: [...snapshot.warnings],
hasRunningTasks: snapshot.hasRunningTasks,
hasRunningTasks: false, // 任务不在更新中心执行
});
const normalizeLoadedItems = (
@@ -152,12 +137,8 @@ export const createUpdateCenterService = (
options.saveIgnoredEntries ??
((entries: ReadonlySet<string>) =>
saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries));
const createRunner =
options.createTaskRunner ??
((currentQueue: UpdateCenterQueue, superUserCmd?: string) =>
createTaskRunner(currentQueue, { superUserCmd }));
let processingPromise: Promise<void> | null = null;
let activeRunner: UpdateCenterTaskRunner | null = null;
let nextUpdateTaskId = 1;
const applyWarning = (message: string): void => {
queue.finishRefresh([message]);
@@ -167,9 +148,9 @@ export const createUpdateCenterService = (
const emit = (): UpdateCenterServiceState => {
const snapshot = getState();
for (const listener of listeners) {
listeners.forEach((listener) => {
listener(snapshot);
}
});
return snapshot;
};
@@ -192,47 +173,6 @@ export const createUpdateCenterService = (
}
};
const failQueuedTasks = (message: string): void => {
for (const task of queue.getSnapshot().tasks) {
if (task.status === "queued") {
queue.appendTaskLog(task.id, message);
queue.finishTask(task.id, "failed", message);
}
}
};
const ensureProcessing = async (): Promise<void> => {
if (processingPromise) {
return processingPromise;
}
processingPromise = (async () => {
let superUserCmd = "";
try {
superUserCmd = (await options.superUserCmdProvider?.()) ?? "";
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failQueuedTasks(message);
applyWarning(message);
emit();
return;
}
activeRunner = createRunner(queue, superUserCmd);
while (queue.getNextQueuedTask()) {
await activeRunner.runNextTask();
emit();
}
})().finally(() => {
processingPromise = null;
activeRunner = null;
});
return processingPromise;
};
return {
open: refresh,
refresh,
@@ -250,47 +190,63 @@ export const createUpdateCenterService = (
},
async start(taskKeys) {
const snapshot = queue.getSnapshot();
const existingTaskKeys = new Set(
snapshot.tasks
.filter(
(task) =>
!["completed", "failed", "cancelled"].includes(task.status),
)
.map((task) => getTaskKey(task.item)),
);
const selectedItems = snapshot.items.filter(
(item) =>
taskKeys.includes(getTaskKey(item)) &&
!item.ignored &&
!existingTaskKeys.has(getTaskKey(item)),
!item.ignored,
);
if (selectedItems.length === 0) {
return;
}
for (const item of selectedItems) {
queue.enqueueItem(item);
}
emit();
// 获取主窗口的 webContents
const mainWindow = BrowserWindow.getAllWindows()[0];
const webContents = mainWindow?.webContents;
await ensureProcessing();
},
async cancel(taskKey) {
const task = queue
.getSnapshot()
.tasks.find((entry) => getTaskKey(entry.item) === taskKey);
if (!task) {
if (!webContents) {
console.error("No main window found");
return;
}
queue.finishTask(task.id, "cancelled", "Cancelled");
if (["downloading", "installing"].includes(task.status)) {
activeRunner?.cancelActiveTask();
// 获取当前 items
let currentItems = snapshot.items;
for (const item of selectedItems) {
const updateTaskId = nextUpdateTaskId++;
// 构建 metalink URL
const metalinkUrl = item.downloadUrl
? `${item.downloadUrl}.metalink`
: undefined;
// 发送到主下载队列
const installTaskData = {
id: updateTaskId,
pkgname: item.pkgname,
metalinkUrl,
filename: item.fileName,
upgradeOnly: true,
origin: item.source === "apm" ? "apm" : "spark",
retry: false,
};
// 通过 IPC 发送到主下载队列
webContents.send("queue-install", JSON.stringify(installTaskData));
// 从更新中心的 items 中移除该应用(不再显示在更新列表中)
currentItems = currentItems.filter((i) => getTaskKey(i) !== getTaskKey(item));
}
// 更新队列中的 items
queue.setItems(currentItems);
emit();
},
async cancel(taskKey) {
// 取消功能不再需要通过更新中心,直接忽略
console.log("Cancel not needed for task:", taskKey);
},
getState,
subscribe(listener) {
listeners.add(listener);