mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-26 01:10:16 +08:00
feat(update-center): 实现集中式软件更新中心功能
新增更新中心模块,支持管理 APM 和传统 deb 软件更新任务 - 添加更新任务队列管理、状态跟踪和日志记录功能 - 实现更新项忽略配置持久化存储 - 新增更新确认对话框和迁移提示 - 优化主窗口关闭时的任务保护机制 - 添加单元测试覆盖核心逻辑
This commit is contained in:
@@ -31,7 +31,7 @@ export const tasks = new Map<number, InstallTask>();
|
||||
|
||||
let idle = true; // Indicates if the installation manager is idle
|
||||
|
||||
const checkSuperUserCommand = async (): Promise<string> => {
|
||||
export const checkSuperUserCommand = async (): Promise<string> => {
|
||||
let superUserCmd = "";
|
||||
const execAsync = promisify(exec);
|
||||
if (process.getuid && process.getuid() !== 0) {
|
||||
@@ -251,8 +251,9 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
||||
if (origin === "spark") {
|
||||
// Spark Store logic
|
||||
if (upgradeOnly) {
|
||||
execCommand = "pkexec";
|
||||
execParams.push("spark-update-tool", pkgname);
|
||||
execCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
|
||||
execParams.push("aptss", "install", "-y", pkgname, "--only-upgrade");
|
||||
} else {
|
||||
execCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
|
||||
|
||||
87
electron/main/backend/update-center/download.ts
Normal file
87
electron/main/backend/update-center/download.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import type { UpdateCenterItem } from "./types";
|
||||
|
||||
export interface Aria2DownloadResult {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface RunAria2DownloadOptions {
|
||||
item: UpdateCenterItem;
|
||||
downloadDir: string;
|
||||
onProgress?: (progress: number) => void;
|
||||
onLog?: (message: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const PROGRESS_PATTERN = /(\d{1,3}(?:\.\d+)?)%/;
|
||||
|
||||
export const runAria2Download = async ({
|
||||
item,
|
||||
downloadDir,
|
||||
onProgress,
|
||||
onLog,
|
||||
signal,
|
||||
}: RunAria2DownloadOptions): Promise<Aria2DownloadResult> => {
|
||||
if (!item.downloadUrl || !item.fileName) {
|
||||
throw new Error(`Missing download metadata for ${item.pkgname}`);
|
||||
}
|
||||
|
||||
await mkdir(downloadDir, { recursive: true });
|
||||
|
||||
const filePath = join(downloadDir, item.fileName);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("aria2c", [
|
||||
"--dir",
|
||||
downloadDir,
|
||||
"--out",
|
||||
item.fileName,
|
||||
item.downloadUrl,
|
||||
]);
|
||||
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
|
||||
onProgress?.(100);
|
||||
|
||||
return { filePath };
|
||||
};
|
||||
79
electron/main/backend/update-center/ignore-config.ts
Normal file
79
electron/main/backend/update-center/ignore-config.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import type { UpdateCenterItem } from "./types";
|
||||
|
||||
export const LEGACY_IGNORE_CONFIG_PATH = "/etc/spark-store/ignored_apps.conf";
|
||||
|
||||
const LEGACY_IGNORE_SEPARATOR = "|";
|
||||
|
||||
export const createIgnoreKey = (pkgname: string, version: string): string =>
|
||||
`${pkgname}${LEGACY_IGNORE_SEPARATOR}${version}`;
|
||||
|
||||
export const parseIgnoredEntries = (content: string): Set<string> => {
|
||||
const ignoredEntries = new Set<string>();
|
||||
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = trimmed.split(LEGACY_IGNORE_SEPARATOR);
|
||||
if (parts.length !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [pkgname, version] = parts;
|
||||
if (!pkgname || !version) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ignoredEntries.add(createIgnoreKey(pkgname, version));
|
||||
}
|
||||
|
||||
return ignoredEntries;
|
||||
};
|
||||
|
||||
export const loadIgnoredEntries = async (
|
||||
filePath: string,
|
||||
): Promise<Set<string>> => {
|
||||
try {
|
||||
const content = await readFile(filePath, "utf8");
|
||||
return parseIgnoredEntries(content);
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveIgnoredEntries = async (
|
||||
filePath: string,
|
||||
ignoredEntries: ReadonlySet<string>,
|
||||
): Promise<void> => {
|
||||
const sortedEntries = Array.from(ignoredEntries).sort();
|
||||
const content =
|
||||
sortedEntries.length > 0 ? `${sortedEntries.join("\n")}\n` : "";
|
||||
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content, "utf8");
|
||||
};
|
||||
|
||||
export const applyIgnoredEntries = (
|
||||
items: UpdateCenterItem[],
|
||||
ignoredEntries: ReadonlySet<string>,
|
||||
): UpdateCenterItem[] =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
ignored: ignoredEntries.has(
|
||||
createIgnoreKey(item.pkgname, item.nextVersion),
|
||||
),
|
||||
}));
|
||||
253
electron/main/backend/update-center/index.ts
Normal file
253
electron/main/backend/update-center/index.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
import {
|
||||
buildInstalledSourceMap,
|
||||
mergeUpdateSources,
|
||||
parseApmUpgradableOutput,
|
||||
parseAptssUpgradableOutput,
|
||||
parsePrintUrisOutput,
|
||||
} from "./query";
|
||||
import {
|
||||
createUpdateCenterService,
|
||||
type UpdateCenterIgnorePayload,
|
||||
type UpdateCenterService,
|
||||
} from "./service";
|
||||
import type { UpdateCenterItem } from "./types";
|
||||
|
||||
export interface UpdateCenterCommandResult {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export type UpdateCenterCommandRunner = (
|
||||
command: string,
|
||||
args: string[],
|
||||
) => Promise<UpdateCenterCommandResult>;
|
||||
|
||||
export interface UpdateCenterLoadItemsResult {
|
||||
items: UpdateCenterItem[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const APTSS_LIST_UPGRADABLE_COMMAND = {
|
||||
command: "bash",
|
||||
args: [
|
||||
"-lc",
|
||||
"env LANGUAGE=en_US /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf list --upgradable -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null -o APT::Get::List-Cleanup=0",
|
||||
],
|
||||
};
|
||||
|
||||
const DPKG_QUERY_INSTALLED_COMMAND = {
|
||||
command: "dpkg-query",
|
||||
args: [
|
||||
"-W",
|
||||
"-f=${Package}\t${db:Status-Want} ${db:Status-Status} ${db:Status-Eflag}\n",
|
||||
],
|
||||
};
|
||||
|
||||
const runCommandCapture: UpdateCenterCommandRunner = async (
|
||||
command,
|
||||
args,
|
||||
): Promise<UpdateCenterCommandResult> =>
|
||||
await new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
shell: false,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
resolve({ code: -1, stdout, stderr: error.message });
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
resolve({ code: code ?? -1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
|
||||
const getCommandError = (
|
||||
label: string,
|
||||
result: UpdateCenterCommandResult,
|
||||
): string | null => {
|
||||
if (result.code === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${label} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`;
|
||||
};
|
||||
|
||||
const loadApmItemMetadata = async (
|
||||
item: UpdateCenterItem,
|
||||
runCommand: UpdateCenterCommandRunner,
|
||||
): Promise<
|
||||
| { item: UpdateCenterItem; warning?: undefined }
|
||||
| { item: null; warning: string }
|
||||
> => {
|
||||
const metadataResult = await runCommand("apm", [
|
||||
"info",
|
||||
item.pkgname,
|
||||
"--print-uris",
|
||||
]);
|
||||
const commandError = getCommandError(
|
||||
`apm metadata query for ${item.pkgname}`,
|
||||
metadataResult,
|
||||
);
|
||||
if (commandError) {
|
||||
return { item: null, warning: commandError };
|
||||
}
|
||||
|
||||
const metadata = parsePrintUrisOutput(metadataResult.stdout);
|
||||
if (!metadata) {
|
||||
return {
|
||||
item: null,
|
||||
warning: `apm metadata query for ${item.pkgname} returned no package metadata`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
item: {
|
||||
...item,
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const enrichApmItems = async (
|
||||
items: UpdateCenterItem[],
|
||||
runCommand: UpdateCenterCommandRunner,
|
||||
): Promise<UpdateCenterLoadItemsResult> => {
|
||||
const results = await Promise.all(
|
||||
items.map((item) => loadApmItemMetadata(item, runCommand)),
|
||||
);
|
||||
|
||||
return {
|
||||
items: results.flatMap((result) => (result.item ? [result.item] : [])),
|
||||
warnings: results.flatMap((result) =>
|
||||
result.warning ? [result.warning] : [],
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const loadUpdateCenterItems = async (
|
||||
runCommand: UpdateCenterCommandRunner = runCommandCapture,
|
||||
): Promise<UpdateCenterLoadItemsResult> => {
|
||||
const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] =
|
||||
await Promise.all([
|
||||
runCommand(
|
||||
APTSS_LIST_UPGRADABLE_COMMAND.command,
|
||||
APTSS_LIST_UPGRADABLE_COMMAND.args,
|
||||
),
|
||||
runCommand("apm", ["list", "--upgradable"]),
|
||||
runCommand(
|
||||
DPKG_QUERY_INSTALLED_COMMAND.command,
|
||||
DPKG_QUERY_INSTALLED_COMMAND.args,
|
||||
),
|
||||
runCommand("apm", ["list", "--installed"]),
|
||||
]);
|
||||
|
||||
const warnings = [
|
||||
getCommandError("aptss upgradable query", aptssResult),
|
||||
getCommandError("apm upgradable query", apmResult),
|
||||
getCommandError("dpkg installed query", aptssInstalledResult),
|
||||
getCommandError("apm installed query", apmInstalledResult),
|
||||
].filter((message): message is string => message !== null);
|
||||
|
||||
const aptssItems =
|
||||
aptssResult.code === 0
|
||||
? parseAptssUpgradableOutput(aptssResult.stdout)
|
||||
: [];
|
||||
const apmItems =
|
||||
apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : [];
|
||||
|
||||
if (aptssResult.code !== 0 && apmResult.code !== 0) {
|
||||
throw new Error(warnings.join("; "));
|
||||
}
|
||||
|
||||
const installedSources = buildInstalledSourceMap(
|
||||
aptssInstalledResult.code === 0 ? aptssInstalledResult.stdout : "",
|
||||
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
|
||||
);
|
||||
|
||||
const enrichedApmItems = await enrichApmItems(apmItems, runCommand);
|
||||
|
||||
return {
|
||||
items: mergeUpdateSources(
|
||||
aptssItems,
|
||||
enrichedApmItems.items,
|
||||
installedSources,
|
||||
),
|
||||
warnings: [...warnings, ...enrichedApmItems.warnings],
|
||||
};
|
||||
};
|
||||
|
||||
export const registerUpdateCenterIpc = (
|
||||
ipc: Pick<typeof ipcMain, "handle">,
|
||||
service: Pick<
|
||||
UpdateCenterService,
|
||||
| "open"
|
||||
| "refresh"
|
||||
| "ignore"
|
||||
| "unignore"
|
||||
| "start"
|
||||
| "cancel"
|
||||
| "getState"
|
||||
| "subscribe"
|
||||
>,
|
||||
): void => {
|
||||
ipc.handle("update-center-open", () => service.open());
|
||||
ipc.handle("update-center-refresh", () => service.refresh());
|
||||
ipc.handle(
|
||||
"update-center-ignore",
|
||||
(_event, payload: UpdateCenterIgnorePayload) => service.ignore(payload),
|
||||
);
|
||||
ipc.handle(
|
||||
"update-center-unignore",
|
||||
(_event, payload: UpdateCenterIgnorePayload) => service.unignore(payload),
|
||||
);
|
||||
ipc.handle("update-center-start", (_event, taskKeys: string[]) =>
|
||||
service.start(taskKeys),
|
||||
);
|
||||
ipc.handle("update-center-cancel", (_event, taskKey: string) =>
|
||||
service.cancel(taskKey),
|
||||
);
|
||||
ipc.handle("update-center-get-state", () => service.getState());
|
||||
|
||||
service.subscribe((snapshot) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send("update-center-state", snapshot);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let updateCenterService: UpdateCenterService | null = null;
|
||||
|
||||
export const initializeUpdateCenter = (): UpdateCenterService => {
|
||||
if (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);
|
||||
|
||||
return updateCenterService;
|
||||
};
|
||||
|
||||
export { createUpdateCenterService } from "./service";
|
||||
318
electron/main/backend/update-center/install.ts
Normal file
318
electron/main/backend/update-center/install.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
370
electron/main/backend/update-center/query.ts
Normal file
370
electron/main/backend/update-center/query.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import * as childProcess from "node:child_process";
|
||||
|
||||
import type {
|
||||
InstalledSourceState,
|
||||
UpdateCenterItem,
|
||||
UpdateSource,
|
||||
} from "./types";
|
||||
|
||||
const UPGRADABLE_PATTERN =
|
||||
/^(\S+)\/\S+\s+([^\s]+)\s+\S+\s+\[(?:upgradable from|from):\s*([^\]]+)\]$/i;
|
||||
const PRINT_URIS_PATTERN = /'([^']+)'\s+(\S+)\s+(\d+)\s+SHA512:([^\s]+)/;
|
||||
const APM_INSTALLED_PATTERN = /^(\S+)\/\S+(?:,\S+)?\s+\S+\s+\S+\s+\[[^\]]+\]$/;
|
||||
|
||||
const splitVersion = (version: string) => {
|
||||
const epochMatch = version.match(/^(\d+):(.*)$/);
|
||||
const epoch = epochMatch ? Number(epochMatch[1]) : 0;
|
||||
const remainder = epochMatch ? epochMatch[2] : version;
|
||||
const hyphenIndex = remainder.lastIndexOf("-");
|
||||
|
||||
return {
|
||||
epoch,
|
||||
upstream: hyphenIndex === -1 ? remainder : remainder.slice(0, hyphenIndex),
|
||||
revision: hyphenIndex === -1 ? "" : remainder.slice(hyphenIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const getNonDigitOrder = (char: string | undefined): number => {
|
||||
if (char === "~") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!char) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (/[A-Za-z]/.test(char)) {
|
||||
return char.charCodeAt(0);
|
||||
}
|
||||
|
||||
return char.charCodeAt(0) + 256;
|
||||
};
|
||||
|
||||
const compareNonDigitPart = (left: string, right: string): number => {
|
||||
let leftIndex = 0;
|
||||
let rightIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const leftChar = left[leftIndex];
|
||||
const rightChar = right[rightIndex];
|
||||
|
||||
const leftIsDigit = leftChar !== undefined && /\d/.test(leftChar);
|
||||
const rightIsDigit = rightChar !== undefined && /\d/.test(rightChar);
|
||||
|
||||
if (
|
||||
(leftChar === undefined || leftIsDigit) &&
|
||||
(rightChar === undefined || rightIsDigit)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const leftOrder = getNonDigitOrder(leftIsDigit ? undefined : leftChar);
|
||||
const rightOrder = getNonDigitOrder(rightIsDigit ? undefined : rightChar);
|
||||
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder < rightOrder ? -1 : 1;
|
||||
}
|
||||
|
||||
if (!leftIsDigit && leftChar !== undefined) {
|
||||
leftIndex += 1;
|
||||
}
|
||||
|
||||
if (!rightIsDigit && rightChar !== undefined) {
|
||||
rightIndex += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const compareDigitPart = (left: string, right: string): number => {
|
||||
const normalizedLeft = left.replace(/^0+/, "");
|
||||
const normalizedRight = right.replace(/^0+/, "");
|
||||
|
||||
if (normalizedLeft.length !== normalizedRight.length) {
|
||||
return normalizedLeft.length < normalizedRight.length ? -1 : 1;
|
||||
}
|
||||
|
||||
if (normalizedLeft === normalizedRight) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return normalizedLeft < normalizedRight ? -1 : 1;
|
||||
};
|
||||
|
||||
const compareVersionPart = (left: string, right: string): number => {
|
||||
let leftIndex = 0;
|
||||
let rightIndex = 0;
|
||||
|
||||
while (leftIndex < left.length || rightIndex < right.length) {
|
||||
const nonDigitResult = compareNonDigitPart(
|
||||
left.slice(leftIndex),
|
||||
right.slice(rightIndex),
|
||||
);
|
||||
if (nonDigitResult !== 0) {
|
||||
return nonDigitResult;
|
||||
}
|
||||
|
||||
while (leftIndex < left.length && !/\d/.test(left[leftIndex])) {
|
||||
leftIndex += 1;
|
||||
}
|
||||
|
||||
while (rightIndex < right.length && !/\d/.test(right[rightIndex])) {
|
||||
rightIndex += 1;
|
||||
}
|
||||
|
||||
let leftDigitsEnd = leftIndex;
|
||||
let rightDigitsEnd = rightIndex;
|
||||
|
||||
while (leftDigitsEnd < left.length && /\d/.test(left[leftDigitsEnd])) {
|
||||
leftDigitsEnd += 1;
|
||||
}
|
||||
|
||||
while (rightDigitsEnd < right.length && /\d/.test(right[rightDigitsEnd])) {
|
||||
rightDigitsEnd += 1;
|
||||
}
|
||||
|
||||
const digitResult = compareDigitPart(
|
||||
left.slice(leftIndex, leftDigitsEnd),
|
||||
right.slice(rightIndex, rightDigitsEnd),
|
||||
);
|
||||
if (digitResult !== 0) {
|
||||
return digitResult;
|
||||
}
|
||||
|
||||
leftIndex = leftDigitsEnd;
|
||||
rightIndex = rightDigitsEnd;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const fallbackCompareVersions = (left: string, right: string): number => {
|
||||
const leftVersion = splitVersion(left);
|
||||
const rightVersion = splitVersion(right);
|
||||
|
||||
if (leftVersion.epoch !== rightVersion.epoch) {
|
||||
return leftVersion.epoch < rightVersion.epoch ? -1 : 1;
|
||||
}
|
||||
|
||||
const upstreamResult = compareVersionPart(
|
||||
leftVersion.upstream,
|
||||
rightVersion.upstream,
|
||||
);
|
||||
if (upstreamResult !== 0) {
|
||||
return upstreamResult;
|
||||
}
|
||||
|
||||
return compareVersionPart(leftVersion.revision, rightVersion.revision);
|
||||
};
|
||||
|
||||
const runDpkgVersionCheck = (
|
||||
left: string,
|
||||
operator: "gt" | "lt",
|
||||
right: string,
|
||||
): boolean | null => {
|
||||
const result = childProcess.spawnSync("dpkg", [
|
||||
"--compare-versions",
|
||||
left,
|
||||
operator,
|
||||
right,
|
||||
]);
|
||||
|
||||
if (result.error || typeof result.status !== "number") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.status === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result.status === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseUpgradableOutput = (
|
||||
output: string,
|
||||
source: UpdateSource,
|
||||
): UpdateCenterItem[] => {
|
||||
const items: UpdateCenterItem[] = [];
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("Listing")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = trimmed.match(UPGRADABLE_PATTERN);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, pkgname, nextVersion, currentVersion] = match;
|
||||
if (!pkgname || nextVersion === currentVersion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
pkgname,
|
||||
source,
|
||||
currentVersion,
|
||||
nextVersion,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const getInstalledState = (
|
||||
installedSources: Map<string, InstalledSourceState>,
|
||||
pkgname: string,
|
||||
): InstalledSourceState => {
|
||||
const existing = installedSources.get(pkgname);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const state: InstalledSourceState = { aptss: false, apm: false };
|
||||
installedSources.set(pkgname, state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const compareVersions = (left: string, right: string): number => {
|
||||
const greaterThan = runDpkgVersionCheck(left, "gt", right);
|
||||
if (greaterThan === true) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const lessThan = runDpkgVersionCheck(left, "lt", right);
|
||||
if (lessThan === true) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (greaterThan === false && lessThan === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fall back to a numeric-aware string comparison when dpkg is unavailable
|
||||
// or returns an unusable result, rather than silently treating versions as equal.
|
||||
return fallbackCompareVersions(left, right);
|
||||
};
|
||||
|
||||
export const parseAptssUpgradableOutput = (
|
||||
output: string,
|
||||
): UpdateCenterItem[] => parseUpgradableOutput(output, "aptss");
|
||||
|
||||
export const parseApmUpgradableOutput = (output: string): UpdateCenterItem[] =>
|
||||
parseUpgradableOutput(output, "apm");
|
||||
|
||||
export const parsePrintUrisOutput = (
|
||||
output: string,
|
||||
): Pick<
|
||||
UpdateCenterItem,
|
||||
"downloadUrl" | "fileName" | "size" | "sha512"
|
||||
> | null => {
|
||||
const match = output.trim().match(PRINT_URIS_PATTERN);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, downloadUrl, fileName, size, sha512] = match;
|
||||
return {
|
||||
downloadUrl,
|
||||
fileName,
|
||||
size: Number(size),
|
||||
sha512,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildInstalledSourceMap = (
|
||||
dpkgQueryOutput: string,
|
||||
apmInstalledOutput: string,
|
||||
): Map<string, InstalledSourceState> => {
|
||||
const installedSources = new Map<string, InstalledSourceState>();
|
||||
|
||||
for (const line of dpkgQueryOutput.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [pkgname, status] = trimmed.split("\t");
|
||||
if (!pkgname || status !== "install ok installed") {
|
||||
continue;
|
||||
}
|
||||
|
||||
getInstalledState(installedSources, pkgname).aptss = true;
|
||||
}
|
||||
|
||||
for (const line of apmInstalledOutput.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("Listing")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!APM_INSTALLED_PATTERN.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pkgname = trimmed.split("/")[0];
|
||||
if (!pkgname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
getInstalledState(installedSources, pkgname).apm = true;
|
||||
}
|
||||
|
||||
return installedSources;
|
||||
};
|
||||
|
||||
export const mergeUpdateSources = (
|
||||
aptssItems: UpdateCenterItem[],
|
||||
apmItems: UpdateCenterItem[],
|
||||
installedSources: Map<string, InstalledSourceState>,
|
||||
): UpdateCenterItem[] => {
|
||||
const aptssMap = new Map(aptssItems.map((item) => [item.pkgname, item]));
|
||||
const apmMap = new Map(apmItems.map((item) => [item.pkgname, item]));
|
||||
const merged: UpdateCenterItem[] = [];
|
||||
|
||||
for (const item of aptssItems) {
|
||||
if (!apmMap.has(item.pkgname)) {
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of apmItems) {
|
||||
if (!aptssMap.has(item.pkgname)) {
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const aptssItem of aptssItems) {
|
||||
const apmItem = apmMap.get(aptssItem.pkgname);
|
||||
if (!apmItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const installedState = installedSources.get(aptssItem.pkgname);
|
||||
const isMigration =
|
||||
installedState?.aptss === true &&
|
||||
installedState.apm === false &&
|
||||
compareVersions(apmItem.nextVersion, aptssItem.nextVersion) > 0;
|
||||
|
||||
if (isMigration) {
|
||||
merged.push({
|
||||
...apmItem,
|
||||
isMigration: true,
|
||||
migrationSource: "aptss",
|
||||
migrationTarget: "apm",
|
||||
aptssVersion: aptssItem.nextVersion,
|
||||
});
|
||||
merged.push(aptssItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push(aptssItem, apmItem);
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
158
electron/main/backend/update-center/queue.ts
Normal file
158
electron/main/backend/update-center/queue.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { UpdateCenterItem } from "./types";
|
||||
|
||||
export type UpdateCenterTaskStatus =
|
||||
| "queued"
|
||||
| "downloading"
|
||||
| "installing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface UpdateCenterTaskLog {
|
||||
time: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UpdateCenterTask {
|
||||
id: number;
|
||||
pkgname: string;
|
||||
item: UpdateCenterItem;
|
||||
status: UpdateCenterTaskStatus;
|
||||
progress: number;
|
||||
logs: UpdateCenterTaskLog[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCenterQueueSnapshot {
|
||||
items: UpdateCenterItem[];
|
||||
tasks: UpdateCenterTask[];
|
||||
warnings: string[];
|
||||
hasRunningTasks: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCenterQueue {
|
||||
setItems: (items: UpdateCenterItem[]) => void;
|
||||
startRefresh: () => void;
|
||||
finishRefresh: (warnings?: string[]) => void;
|
||||
enqueueItem: (item: UpdateCenterItem) => UpdateCenterTask;
|
||||
markActiveTask: (
|
||||
taskId: number,
|
||||
status: Extract<UpdateCenterTaskStatus, "downloading" | "installing">,
|
||||
) => void;
|
||||
updateTaskProgress: (taskId: number, progress: number) => void;
|
||||
appendTaskLog: (taskId: number, message: string, time?: number) => void;
|
||||
finishTask: (
|
||||
taskId: number,
|
||||
status: Extract<
|
||||
UpdateCenterTaskStatus,
|
||||
"completed" | "failed" | "cancelled"
|
||||
>,
|
||||
error?: string,
|
||||
) => void;
|
||||
getNextQueuedTask: () => UpdateCenterTask | undefined;
|
||||
getSnapshot: () => UpdateCenterQueueSnapshot;
|
||||
}
|
||||
|
||||
const clampProgress = (progress: number): number => {
|
||||
if (!Number.isFinite(progress)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progress)));
|
||||
};
|
||||
|
||||
const createSnapshot = (
|
||||
items: UpdateCenterItem[],
|
||||
tasks: UpdateCenterTask[],
|
||||
warnings: string[],
|
||||
refreshing: boolean,
|
||||
): UpdateCenterQueueSnapshot => ({
|
||||
items: items.map((item) => ({ ...item })),
|
||||
tasks: tasks.map((task) => ({
|
||||
...task,
|
||||
item: { ...task.item },
|
||||
logs: task.logs.map((log) => ({ ...log })),
|
||||
})),
|
||||
warnings: [...warnings],
|
||||
hasRunningTasks:
|
||||
refreshing ||
|
||||
tasks.some((task) =>
|
||||
["queued", "downloading", "installing"].includes(task.status),
|
||||
),
|
||||
});
|
||||
|
||||
export const createUpdateCenterQueue = (): UpdateCenterQueue => {
|
||||
let items: UpdateCenterItem[] = [];
|
||||
let tasks: UpdateCenterTask[] = [];
|
||||
let warnings: string[] = [];
|
||||
let refreshing = false;
|
||||
let nextTaskId = 1;
|
||||
|
||||
const getTask = (taskId: number): UpdateCenterTask | undefined =>
|
||||
tasks.find((task) => task.id === taskId);
|
||||
|
||||
return {
|
||||
setItems: (nextItems) => {
|
||||
items = nextItems.map((item) => ({ ...item }));
|
||||
},
|
||||
startRefresh: () => {
|
||||
refreshing = true;
|
||||
},
|
||||
finishRefresh: (nextWarnings = []) => {
|
||||
refreshing = false;
|
||||
warnings = [...nextWarnings];
|
||||
},
|
||||
enqueueItem: (item) => {
|
||||
const task: UpdateCenterTask = {
|
||||
id: nextTaskId,
|
||||
pkgname: item.pkgname,
|
||||
item: { ...item },
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
nextTaskId += 1;
|
||||
tasks = [...tasks, task];
|
||||
return task;
|
||||
},
|
||||
markActiveTask: (taskId, status) => {
|
||||
const task = getTask(taskId);
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.status = status;
|
||||
},
|
||||
updateTaskProgress: (taskId, progress) => {
|
||||
const task = getTask(taskId);
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.progress = clampProgress(progress);
|
||||
},
|
||||
appendTaskLog: (taskId, message, time = Date.now()) => {
|
||||
const task = getTask(taskId);
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.logs = [...task.logs, { time, message }];
|
||||
},
|
||||
finishTask: (taskId, status, error) => {
|
||||
const task = getTask(taskId);
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.status = status;
|
||||
task.error = error;
|
||||
if (status === "completed") {
|
||||
task.progress = 100;
|
||||
}
|
||||
},
|
||||
getNextQueuedTask: () => tasks.find((task) => task.status === "queued"),
|
||||
getSnapshot: () => createSnapshot(items, tasks, warnings, refreshing),
|
||||
};
|
||||
};
|
||||
294
electron/main/backend/update-center/service.ts
Normal file
294
electron/main/backend/update-center/service.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
LEGACY_IGNORE_CONFIG_PATH,
|
||||
applyIgnoredEntries,
|
||||
createIgnoreKey,
|
||||
loadIgnoredEntries,
|
||||
saveIgnoredEntries,
|
||||
} from "./ignore-config";
|
||||
import { createTaskRunner, type UpdateCenterTaskRunner } from "./install";
|
||||
import {
|
||||
createUpdateCenterQueue,
|
||||
type UpdateCenterQueue,
|
||||
type UpdateCenterQueueSnapshot,
|
||||
} from "./queue";
|
||||
import type { UpdateCenterItem, UpdateSource } from "./types";
|
||||
|
||||
export interface UpdateCenterLoadedItems {
|
||||
items: UpdateCenterItem[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface UpdateCenterServiceItem {
|
||||
taskKey: string;
|
||||
packageName: string;
|
||||
displayName: string;
|
||||
currentVersion: string;
|
||||
newVersion: string;
|
||||
source: UpdateSource;
|
||||
ignored?: boolean;
|
||||
downloadUrl?: string;
|
||||
fileName?: string;
|
||||
size?: number;
|
||||
sha512?: string;
|
||||
isMigration?: boolean;
|
||||
migrationSource?: UpdateSource;
|
||||
migrationTarget?: UpdateSource;
|
||||
aptssVersion?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCenterServiceTask {
|
||||
taskKey: string;
|
||||
packageName: string;
|
||||
source: UpdateSource;
|
||||
status: UpdateCenterQueueSnapshot["tasks"][number]["status"];
|
||||
progress: number;
|
||||
logs: UpdateCenterQueueSnapshot["tasks"][number]["logs"];
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
export interface UpdateCenterServiceState {
|
||||
items: UpdateCenterServiceItem[];
|
||||
tasks: UpdateCenterServiceTask[];
|
||||
warnings: string[];
|
||||
hasRunningTasks: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCenterIgnorePayload {
|
||||
packageName: string;
|
||||
newVersion: string;
|
||||
}
|
||||
|
||||
export interface UpdateCenterService {
|
||||
open: () => Promise<UpdateCenterServiceState>;
|
||||
refresh: () => Promise<UpdateCenterServiceState>;
|
||||
ignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
|
||||
unignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
|
||||
start: (taskKeys: string[]) => Promise<void>;
|
||||
cancel: (taskKey: string) => Promise<void>;
|
||||
getState: () => UpdateCenterServiceState;
|
||||
subscribe: (
|
||||
listener: (snapshot: UpdateCenterServiceState) => void,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
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 = (
|
||||
item: Pick<UpdateCenterItem, "pkgname" | "source">,
|
||||
): string => `${item.source}:${item.pkgname}`;
|
||||
|
||||
const toState = (
|
||||
snapshot: UpdateCenterQueueSnapshot,
|
||||
): UpdateCenterServiceState => ({
|
||||
items: snapshot.items.map((item) => ({
|
||||
taskKey: getTaskKey(item),
|
||||
packageName: item.pkgname,
|
||||
displayName: item.pkgname,
|
||||
currentVersion: item.currentVersion,
|
||||
newVersion: item.nextVersion,
|
||||
source: item.source,
|
||||
ignored: item.ignored,
|
||||
downloadUrl: item.downloadUrl,
|
||||
fileName: item.fileName,
|
||||
size: item.size,
|
||||
sha512: item.sha512,
|
||||
isMigration: item.isMigration,
|
||||
migrationSource: item.migrationSource,
|
||||
migrationTarget: item.migrationTarget,
|
||||
aptssVersion: item.aptssVersion,
|
||||
})),
|
||||
tasks: snapshot.tasks.map((task) => ({
|
||||
taskKey: getTaskKey(task.item),
|
||||
packageName: task.pkgname,
|
||||
source: task.item.source,
|
||||
status: task.status,
|
||||
progress: task.progress,
|
||||
logs: task.logs.map((log) => ({ ...log })),
|
||||
errorMessage: task.error ?? "",
|
||||
})),
|
||||
warnings: [...snapshot.warnings],
|
||||
hasRunningTasks: snapshot.hasRunningTasks,
|
||||
});
|
||||
|
||||
const normalizeLoadedItems = (
|
||||
loaded: UpdateCenterItem[] | UpdateCenterLoadedItems,
|
||||
): UpdateCenterLoadedItems => {
|
||||
if (Array.isArray(loaded)) {
|
||||
return { items: loaded, warnings: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
items: loaded.items,
|
||||
warnings: loaded.warnings,
|
||||
};
|
||||
};
|
||||
|
||||
export const createUpdateCenterService = (
|
||||
options: CreateUpdateCenterServiceOptions,
|
||||
): UpdateCenterService => {
|
||||
const queue = createUpdateCenterQueue();
|
||||
const listeners = new Set<(snapshot: UpdateCenterServiceState) => void>();
|
||||
const loadIgnored =
|
||||
options.loadIgnoredEntries ??
|
||||
(() => loadIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH));
|
||||
const saveIgnored =
|
||||
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;
|
||||
|
||||
const applyWarning = (message: string): void => {
|
||||
queue.finishRefresh([message]);
|
||||
};
|
||||
|
||||
const getState = (): UpdateCenterServiceState => toState(queue.getSnapshot());
|
||||
|
||||
const emit = (): UpdateCenterServiceState => {
|
||||
const snapshot = getState();
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const refresh = async (): Promise<UpdateCenterServiceState> => {
|
||||
queue.startRefresh();
|
||||
emit();
|
||||
|
||||
try {
|
||||
const ignoredEntries = await loadIgnored();
|
||||
const loadedItems = normalizeLoadedItems(await options.loadItems());
|
||||
const items = applyIgnoredEntries(loadedItems.items, ignoredEntries);
|
||||
queue.setItems(items);
|
||||
queue.finishRefresh(loadedItems.warnings);
|
||||
return emit();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
queue.setItems([]);
|
||||
applyWarning(message);
|
||||
return emit();
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
async ignore(payload) {
|
||||
const entries = await loadIgnored();
|
||||
entries.add(createIgnoreKey(payload.packageName, payload.newVersion));
|
||||
await saveIgnored(entries);
|
||||
await refresh();
|
||||
},
|
||||
async unignore(payload) {
|
||||
const entries = await loadIgnored();
|
||||
entries.delete(createIgnoreKey(payload.packageName, payload.newVersion));
|
||||
await saveIgnored(entries);
|
||||
await refresh();
|
||||
},
|
||||
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)),
|
||||
);
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of selectedItems) {
|
||||
queue.enqueueItem(item);
|
||||
}
|
||||
emit();
|
||||
|
||||
await ensureProcessing();
|
||||
},
|
||||
async cancel(taskKey) {
|
||||
const task = queue
|
||||
.getSnapshot()
|
||||
.tasks.find((entry) => getTaskKey(entry.item) === taskKey);
|
||||
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
queue.finishTask(task.id, "cancelled", "Cancelled");
|
||||
if (["downloading", "installing"].includes(task.status)) {
|
||||
activeRunner?.cancelActiveTask();
|
||||
}
|
||||
emit();
|
||||
},
|
||||
getState,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
22
electron/main/backend/update-center/types.ts
Normal file
22
electron/main/backend/update-center/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type UpdateSource = "aptss" | "apm";
|
||||
|
||||
export interface InstalledSourceState {
|
||||
aptss: boolean;
|
||||
apm: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCenterItem {
|
||||
pkgname: string;
|
||||
source: UpdateSource;
|
||||
currentVersion: string;
|
||||
nextVersion: string;
|
||||
ignored?: boolean;
|
||||
downloadUrl?: string;
|
||||
fileName?: string;
|
||||
size?: number;
|
||||
sha512?: string;
|
||||
isMigration?: boolean;
|
||||
migrationSource?: UpdateSource;
|
||||
migrationTarget?: UpdateSource;
|
||||
aptssVersion?: string;
|
||||
}
|
||||
@@ -18,6 +18,11 @@ import { handleCommandLine } from "./deeplink.js";
|
||||
import { isLoaded } from "../global.js";
|
||||
import { tasks } from "./backend/install-manager.js";
|
||||
import { sendTelemetryOnce } from "./backend/telemetry.js";
|
||||
import { initializeUpdateCenter } from "./backend/update-center/index.js";
|
||||
import {
|
||||
getMainWindowCloseAction,
|
||||
type MainWindowCloseGuardState,
|
||||
} from "./window-close-guard.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
process.env.APP_ROOT = path.join(__dirname, "../..");
|
||||
@@ -81,6 +86,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
}
|
||||
|
||||
let win: BrowserWindow | null = null;
|
||||
let allowAppExit = false;
|
||||
const preload = path.join(__dirname, "../preload/index.mjs");
|
||||
const indexHtml = path.join(RENDERER_DIST, "index.html");
|
||||
|
||||
@@ -107,6 +113,44 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
|
||||
|
||||
ipcMain.handle("get-app-version", (): string => getAppVersion());
|
||||
|
||||
const getMainWindowCloseGuardState = (): MainWindowCloseGuardState => ({
|
||||
installTaskCount: tasks.size,
|
||||
hasRunningUpdateCenterTasks:
|
||||
initializeUpdateCenter().getState().hasRunningTasks,
|
||||
});
|
||||
|
||||
const applyMainWindowCloseAction = (): void => {
|
||||
if (!win) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = getMainWindowCloseAction(getMainWindowCloseGuardState());
|
||||
if (action === "hide") {
|
||||
win.hide();
|
||||
win.setSkipTaskbar(true);
|
||||
return;
|
||||
}
|
||||
|
||||
win.destroy();
|
||||
};
|
||||
|
||||
const requestApplicationExit = (): void => {
|
||||
if (!win) {
|
||||
allowAppExit = true;
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (getMainWindowCloseAction(getMainWindowCloseGuardState()) === "hide") {
|
||||
win.hide();
|
||||
win.setSkipTaskbar(true);
|
||||
return;
|
||||
}
|
||||
|
||||
allowAppExit = true;
|
||||
app.quit();
|
||||
};
|
||||
|
||||
async function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
title: "星火应用商店",
|
||||
@@ -148,16 +192,13 @@ async function createWindow() {
|
||||
// win.webContents.on('will-navigate', (event, url) => { }) #344
|
||||
|
||||
win.on("close", (event) => {
|
||||
if (allowAppExit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 截获 close 默认行为
|
||||
event.preventDefault();
|
||||
// 点击关闭时触发close事件,我们按照之前的思路在关闭时,隐藏窗口,隐藏任务栏窗口
|
||||
if (tasks.size > 0) {
|
||||
win.hide();
|
||||
win.setSkipTaskbar(true);
|
||||
} else {
|
||||
// 如果没有下载任务,才允许关闭窗口
|
||||
win.destroy();
|
||||
}
|
||||
applyMainWindowCloseAction();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,26 +214,6 @@ ipcMain.on("set-theme-source", (event, theme: "system" | "light" | "dark") => {
|
||||
nativeTheme.themeSource = theme;
|
||||
});
|
||||
|
||||
// 启动系统更新工具(使用 pkexec 提升权限)
|
||||
ipcMain.handle("run-update-tool", async () => {
|
||||
try {
|
||||
const { spawn } = await import("node:child_process");
|
||||
const pkexecPath = "/usr/bin/pkexec";
|
||||
const args = ["spark-update-tool"];
|
||||
const child = spawn(pkexecPath, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
// 让子进程在后台运行且不影响主进程退出
|
||||
child.unref();
|
||||
logger.info("Launched pkexec spark-update-tool");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to launch spark-update-tool");
|
||||
return { success: false, message: (err as Error)?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
// 启动安装设置脚本(可能需要提升权限)
|
||||
ipcMain.handle("open-install-settings", async () => {
|
||||
try {
|
||||
@@ -220,12 +241,14 @@ app.whenReady().then(() => {
|
||||
});
|
||||
createWindow();
|
||||
handleCommandLine(process.argv);
|
||||
initializeUpdateCenter();
|
||||
// 启动后执行一次遥测(仅 Linux,不阻塞)
|
||||
sendTelemetryOnce(getAppVersion());
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
win = null;
|
||||
allowAppExit = false;
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
@@ -302,7 +325,7 @@ app.whenReady().then(() => {
|
||||
{
|
||||
label: "退出程序",
|
||||
click: () => {
|
||||
win.destroy();
|
||||
requestApplicationExit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
15
electron/main/window-close-guard.ts
Normal file
15
electron/main/window-close-guard.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface MainWindowCloseGuardState {
|
||||
installTaskCount: number;
|
||||
hasRunningUpdateCenterTasks: boolean;
|
||||
}
|
||||
|
||||
export type MainWindowCloseAction = "hide" | "destroy";
|
||||
|
||||
export const shouldPreventMainWindowClose = (
|
||||
state: MainWindowCloseGuardState,
|
||||
): boolean => state.installTaskCount > 0 || state.hasRunningUpdateCenterTasks;
|
||||
|
||||
export const getMainWindowCloseAction = (
|
||||
state: MainWindowCloseGuardState,
|
||||
): MainWindowCloseAction =>
|
||||
shouldPreventMainWindowClose(state) ? "hide" : "destroy";
|
||||
@@ -1,4 +1,47 @@
|
||||
import { ipcRenderer, contextBridge } from "electron";
|
||||
import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron";
|
||||
|
||||
type UpdateCenterSnapshot = {
|
||||
items: Array<{
|
||||
taskKey: string;
|
||||
packageName: string;
|
||||
displayName: string;
|
||||
currentVersion: string;
|
||||
newVersion: string;
|
||||
source: "aptss" | "apm";
|
||||
ignored?: boolean;
|
||||
}>;
|
||||
tasks: Array<{
|
||||
taskKey: string;
|
||||
packageName: string;
|
||||
source: "aptss" | "apm";
|
||||
status:
|
||||
| "queued"
|
||||
| "downloading"
|
||||
| "installing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
progress: number;
|
||||
logs: Array<{ time: number; message: string }>;
|
||||
errorMessage: string;
|
||||
}>;
|
||||
warnings: string[];
|
||||
hasRunningTasks: boolean;
|
||||
};
|
||||
|
||||
type IpcRendererFacade = {
|
||||
on: typeof ipcRenderer.on;
|
||||
off: typeof ipcRenderer.off;
|
||||
send: typeof ipcRenderer.send;
|
||||
invoke: typeof ipcRenderer.invoke;
|
||||
};
|
||||
|
||||
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
|
||||
|
||||
const updateCenterStateListeners = new Map<
|
||||
UpdateCenterStateListener,
|
||||
(_event: IpcRendererEvent, snapshot: UpdateCenterSnapshot) => void
|
||||
>();
|
||||
|
||||
// --------- Expose some API to the Renderer process ---------
|
||||
contextBridge.exposeInMainWorld("ipcRenderer", {
|
||||
@@ -23,7 +66,7 @@ contextBridge.exposeInMainWorld("ipcRenderer", {
|
||||
|
||||
// You can expose other APTs you need here.
|
||||
// ...
|
||||
});
|
||||
} satisfies IpcRendererFacade);
|
||||
|
||||
contextBridge.exposeInMainWorld("apm_store", {
|
||||
arch: (() => {
|
||||
@@ -38,6 +81,46 @@ contextBridge.exposeInMainWorld("apm_store", {
|
||||
})(),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("updateCenter", {
|
||||
open: (): Promise<UpdateCenterSnapshot> =>
|
||||
ipcRenderer.invoke("update-center-open"),
|
||||
refresh: (): Promise<UpdateCenterSnapshot> =>
|
||||
ipcRenderer.invoke("update-center-refresh"),
|
||||
ignore: (payload: {
|
||||
packageName: string;
|
||||
newVersion: string;
|
||||
}): Promise<void> => ipcRenderer.invoke("update-center-ignore", payload),
|
||||
unignore: (payload: {
|
||||
packageName: string;
|
||||
newVersion: string;
|
||||
}): Promise<void> => ipcRenderer.invoke("update-center-unignore", payload),
|
||||
start: (taskKeys: string[]): Promise<void> =>
|
||||
ipcRenderer.invoke("update-center-start", taskKeys),
|
||||
cancel: (taskKey: string): Promise<void> =>
|
||||
ipcRenderer.invoke("update-center-cancel", taskKey),
|
||||
getState: (): Promise<UpdateCenterSnapshot> =>
|
||||
ipcRenderer.invoke("update-center-get-state"),
|
||||
onState: (listener: UpdateCenterStateListener): void => {
|
||||
const wrapped = (
|
||||
_event: IpcRendererEvent,
|
||||
snapshot: UpdateCenterSnapshot,
|
||||
) => {
|
||||
listener(snapshot);
|
||||
};
|
||||
updateCenterStateListeners.set(listener, wrapped);
|
||||
ipcRenderer.on("update-center-state", wrapped);
|
||||
},
|
||||
offState: (listener: UpdateCenterStateListener): void => {
|
||||
const wrapped = updateCenterStateListeners.get(listener);
|
||||
if (!wrapped) {
|
||||
return;
|
||||
}
|
||||
|
||||
ipcRenderer.off("update-center-state", wrapped);
|
||||
updateCenterStateListeners.delete(listener);
|
||||
},
|
||||
});
|
||||
|
||||
// --------- Preload scripts loading ---------
|
||||
function domReady(
|
||||
condition: DocumentReadyState[] = ["complete", "interactive"],
|
||||
|
||||
Reference in New Issue
Block a user