mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-30 03:10:16 +08:00
复用下载中心(1/2)
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user