mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-26 01:10:16 +08:00
新增更新中心模块,支持管理 APM 和传统 deb 软件更新任务 - 添加更新任务队列管理、状态跟踪和日志记录功能 - 实现更新项忽略配置持久化存储 - 新增更新确认对话框和迁移提示 - 优化主窗口关闭时的任务保护机制 - 添加单元测试覆盖核心逻辑
319 lines
8.0 KiB
TypeScript
319 lines
8.0 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { join } from "node:path";
|
|
|
|
import { runAria2Download, type Aria2DownloadResult } from "./download";
|
|
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;
|
|
superUserCmd?: string;
|
|
onLog?: (message: string) => void;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export interface TaskRunnerDownloadContext {
|
|
item: UpdateCenterItem;
|
|
task: UpdateCenterTask;
|
|
onProgress: (progress: number) => void;
|
|
onLog: (message: string) => void;
|
|
signal: AbortSignal;
|
|
}
|
|
|
|
export interface TaskRunnerInstallContext {
|
|
item: UpdateCenterItem;
|
|
task: UpdateCenterTask;
|
|
filePath?: string;
|
|
superUserCmd?: string;
|
|
onLog: (message: string) => void;
|
|
signal: AbortSignal;
|
|
}
|
|
|
|
export interface TaskRunnerDependencies {
|
|
runDownload?: (
|
|
context: TaskRunnerDownloadContext,
|
|
) => Promise<Aria2DownloadResult>;
|
|
installItem?: (context: TaskRunnerInstallContext) => Promise<void>;
|
|
}
|
|
|
|
export interface UpdateCenterTaskRunner {
|
|
runNextTask: () => Promise<UpdateCenterTask | null>;
|
|
cancelActiveTask: () => void;
|
|
}
|
|
|
|
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,
|
|
};
|
|
};
|
|
|
|
export const buildLegacySparkUpgradeCommand = (
|
|
pkgname: string,
|
|
superUserCmd = "",
|
|
): UpdateCommand => {
|
|
if (superUserCmd) {
|
|
return {
|
|
execCommand: superUserCmd,
|
|
execParams: [
|
|
SHELL_CALLER_PATH,
|
|
"aptss",
|
|
"install",
|
|
"-y",
|
|
pkgname,
|
|
"--only-upgrade",
|
|
],
|
|
};
|
|
}
|
|
|
|
return {
|
|
execCommand: SHELL_CALLER_PATH,
|
|
execParams: ["aptss", "install", "-y", pkgname, "--only-upgrade"],
|
|
};
|
|
};
|
|
|
|
export const installUpdateItem = async ({
|
|
item,
|
|
filePath,
|
|
superUserCmd,
|
|
onLog,
|
|
signal,
|
|
}: InstallUpdateItemOptions): Promise<void> => {
|
|
if (item.source === "apm" && !filePath) {
|
|
throw new Error("APM update task requires downloaded package metadata");
|
|
}
|
|
|
|
if (item.source === "apm" && filePath) {
|
|
const auditCommand = buildPrivilegedCommand(
|
|
SHELL_CALLER_PATH,
|
|
["apm", "ssaudit", filePath],
|
|
superUserCmd,
|
|
);
|
|
await runCommand(
|
|
auditCommand.execCommand,
|
|
auditCommand.execParams,
|
|
onLog,
|
|
signal,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (filePath) {
|
|
const installCommand = buildPrivilegedCommand(
|
|
SSINSTALL_PATH,
|
|
[filePath, "--delete-after-install"],
|
|
superUserCmd,
|
|
);
|
|
await runCommand(
|
|
installCommand.execCommand,
|
|
installCommand.execParams,
|
|
onLog,
|
|
signal,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const command = buildLegacySparkUpgradeCommand(
|
|
item.pkgname,
|
|
superUserCmd ?? "",
|
|
);
|
|
await runCommand(command.execCommand, command.execParams, onLog, signal);
|
|
};
|
|
|
|
export const createTaskRunner = (
|
|
queue: UpdateCenterQueue,
|
|
options: CreateTaskRunnerOptions = {},
|
|
): UpdateCenterTaskRunner => {
|
|
const runDownload =
|
|
options.runDownload ??
|
|
((context: TaskRunnerDownloadContext) =>
|
|
runAria2Download({
|
|
item: context.item,
|
|
downloadDir: join(DEFAULT_DOWNLOAD_ROOT, context.item.pkgname),
|
|
onProgress: context.onProgress,
|
|
onLog: context.onLog,
|
|
signal: context.signal,
|
|
}));
|
|
const installItem =
|
|
options.installItem ??
|
|
((context: TaskRunnerInstallContext) =>
|
|
installUpdateItem({
|
|
item: context.item,
|
|
filePath: context.filePath,
|
|
superUserCmd: context.superUserCmd,
|
|
onLog: context.onLog,
|
|
signal: context.signal,
|
|
}));
|
|
let inFlightTask: Promise<UpdateCenterTask | null> | null = null;
|
|
let activeAbortController: AbortController | null = null;
|
|
let activeTaskId: number | null = null;
|
|
|
|
return {
|
|
cancelActiveTask: () => {
|
|
if (!activeAbortController || activeAbortController.signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
activeAbortController.abort();
|
|
},
|
|
runNextTask: async () => {
|
|
if (inFlightTask) {
|
|
return null;
|
|
}
|
|
|
|
inFlightTask = (async () => {
|
|
const task = queue.getNextQueuedTask();
|
|
if (!task) {
|
|
return null;
|
|
}
|
|
|
|
activeTaskId = task.id;
|
|
activeAbortController = new AbortController();
|
|
|
|
const onLog = (message: string) => {
|
|
queue.appendTaskLog(task.id, message);
|
|
};
|
|
|
|
try {
|
|
let filePath: string | undefined;
|
|
|
|
if (
|
|
task.item.source === "apm" &&
|
|
(!task.item.downloadUrl || !task.item.fileName)
|
|
) {
|
|
throw new Error(
|
|
"APM update task requires downloaded package metadata",
|
|
);
|
|
}
|
|
|
|
if (task.item.downloadUrl && task.item.fileName) {
|
|
queue.markActiveTask(task.id, "downloading");
|
|
const result = await runDownload({
|
|
item: task.item,
|
|
task,
|
|
onLog,
|
|
signal: activeAbortController.signal,
|
|
onProgress: (progress) => {
|
|
queue.updateTaskProgress(task.id, progress);
|
|
},
|
|
});
|
|
filePath = result.filePath;
|
|
}
|
|
|
|
queue.markActiveTask(task.id, "installing");
|
|
await installItem({
|
|
item: task.item,
|
|
task,
|
|
filePath,
|
|
superUserCmd: options.superUserCmd,
|
|
onLog,
|
|
signal: activeAbortController.signal,
|
|
});
|
|
|
|
const currentTask = queue
|
|
.getSnapshot()
|
|
.tasks.find((entry) => entry.id === task.id);
|
|
if (currentTask?.status !== "cancelled") {
|
|
queue.finishTask(task.id, "completed");
|
|
}
|
|
return task;
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : String(error);
|
|
const currentTask = queue
|
|
.getSnapshot()
|
|
.tasks.find((entry) => entry.id === task.id);
|
|
if (currentTask?.status !== "cancelled") {
|
|
queue.appendTaskLog(task.id, message);
|
|
queue.finishTask(task.id, "failed", message);
|
|
}
|
|
return task;
|
|
} finally {
|
|
activeAbortController = null;
|
|
activeTaskId = null;
|
|
}
|
|
})();
|
|
|
|
try {
|
|
return await inFlightTask;
|
|
} finally {
|
|
inFlightTask = null;
|
|
if (activeTaskId === null) {
|
|
activeAbortController = null;
|
|
}
|
|
}
|
|
},
|
|
};
|
|
};
|