mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-26 09:20:18 +08:00
!381 feat(滚动): 添加分类切换时重置虚拟滚动位置功能
Merge pull request !381 from momen/feat-electron-update-center
This commit is contained in:
1978
docs/superpowers/plans/2026-04-09-electron-update-center.md
Normal file
1978
docs/superpowers/plans/2026-04-09-electron-update-center.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ export const tasks = new Map<number, InstallTask>();
|
|||||||
|
|
||||||
let idle = true; // Indicates if the installation manager is idle
|
let idle = true; // Indicates if the installation manager is idle
|
||||||
|
|
||||||
const checkSuperUserCommand = async (): Promise<string> => {
|
export const checkSuperUserCommand = async (): Promise<string> => {
|
||||||
let superUserCmd = "";
|
let superUserCmd = "";
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
if (process.getuid && process.getuid() !== 0) {
|
if (process.getuid && process.getuid() !== 0) {
|
||||||
@@ -251,8 +251,9 @@ ipcMain.on("queue-install", async (event, download_json) => {
|
|||||||
if (origin === "spark") {
|
if (origin === "spark") {
|
||||||
// Spark Store logic
|
// Spark Store logic
|
||||||
if (upgradeOnly) {
|
if (upgradeOnly) {
|
||||||
execCommand = "pkexec";
|
execCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||||
execParams.push("spark-update-tool", pkgname);
|
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
|
||||||
|
execParams.push("aptss", "install", "-y", pkgname, "--only-upgrade");
|
||||||
} else {
|
} else {
|
||||||
execCommand = superUserCmd || SHELL_CALLER_PATH;
|
execCommand = superUserCmd || SHELL_CALLER_PATH;
|
||||||
if (superUserCmd) execParams.push(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),
|
||||||
|
),
|
||||||
|
}));
|
||||||
261
electron/main/backend/update-center/index.ts
Normal file
261
electron/main/backend/update-center/index.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
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 getApmPrintUrisCommand = (pkgname: string) => ({
|
||||||
|
command: "bash",
|
||||||
|
args: [
|
||||||
|
"-lc",
|
||||||
|
`amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download ${pkgname} --print-uris`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
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 printUrisCommand = getApmPrintUrisCommand(item.pkgname);
|
||||||
|
const metadataResult = await runCommand(
|
||||||
|
printUrisCommand.command,
|
||||||
|
printUrisCommand.args,
|
||||||
|
);
|
||||||
|
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 installCommand = buildPrivilegedCommand(
|
||||||
|
SHELL_CALLER_PATH,
|
||||||
|
["apm", "ssinstall", filePath],
|
||||||
|
superUserCmd,
|
||||||
|
);
|
||||||
|
await runCommand(
|
||||||
|
installCommand.execCommand,
|
||||||
|
installCommand.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 { isLoaded } from "../global.js";
|
||||||
import { tasks } from "./backend/install-manager.js";
|
import { tasks } from "./backend/install-manager.js";
|
||||||
import { sendTelemetryOnce } from "./backend/telemetry.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));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
process.env.APP_ROOT = path.join(__dirname, "../..");
|
process.env.APP_ROOT = path.join(__dirname, "../..");
|
||||||
@@ -81,6 +86,7 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let win: BrowserWindow | null = null;
|
let win: BrowserWindow | null = null;
|
||||||
|
let allowAppExit = false;
|
||||||
const preload = path.join(__dirname, "../preload/index.mjs");
|
const preload = path.join(__dirname, "../preload/index.mjs");
|
||||||
const indexHtml = path.join(RENDERER_DIST, "index.html");
|
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());
|
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() {
|
async function createWindow() {
|
||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
title: "星火应用商店",
|
title: "星火应用商店",
|
||||||
@@ -148,16 +192,13 @@ async function createWindow() {
|
|||||||
// win.webContents.on('will-navigate', (event, url) => { }) #344
|
// win.webContents.on('will-navigate', (event, url) => { }) #344
|
||||||
|
|
||||||
win.on("close", (event) => {
|
win.on("close", (event) => {
|
||||||
|
if (allowAppExit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 截获 close 默认行为
|
// 截获 close 默认行为
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// 点击关闭时触发close事件,我们按照之前的思路在关闭时,隐藏窗口,隐藏任务栏窗口
|
applyMainWindowCloseAction();
|
||||||
if (tasks.size > 0) {
|
|
||||||
win.hide();
|
|
||||||
win.setSkipTaskbar(true);
|
|
||||||
} else {
|
|
||||||
// 如果没有下载任务,才允许关闭窗口
|
|
||||||
win.destroy();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,26 +214,6 @@ ipcMain.on("set-theme-source", (event, theme: "system" | "light" | "dark") => {
|
|||||||
nativeTheme.themeSource = theme;
|
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 () => {
|
ipcMain.handle("open-install-settings", async () => {
|
||||||
try {
|
try {
|
||||||
@@ -220,12 +241,14 @@ app.whenReady().then(() => {
|
|||||||
});
|
});
|
||||||
createWindow();
|
createWindow();
|
||||||
handleCommandLine(process.argv);
|
handleCommandLine(process.argv);
|
||||||
|
initializeUpdateCenter();
|
||||||
// 启动后执行一次遥测(仅 Linux,不阻塞)
|
// 启动后执行一次遥测(仅 Linux,不阻塞)
|
||||||
sendTelemetryOnce(getAppVersion());
|
sendTelemetryOnce(getAppVersion());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
win = null;
|
win = null;
|
||||||
|
allowAppExit = false;
|
||||||
if (process.platform !== "darwin") app.quit();
|
if (process.platform !== "darwin") app.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -302,7 +325,7 @@ app.whenReady().then(() => {
|
|||||||
{
|
{
|
||||||
label: "退出程序",
|
label: "退出程序",
|
||||||
click: () => {
|
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 ---------
|
// --------- Expose some API to the Renderer process ---------
|
||||||
contextBridge.exposeInMainWorld("ipcRenderer", {
|
contextBridge.exposeInMainWorld("ipcRenderer", {
|
||||||
@@ -23,7 +66,7 @@ contextBridge.exposeInMainWorld("ipcRenderer", {
|
|||||||
|
|
||||||
// You can expose other APTs you need here.
|
// You can expose other APTs you need here.
|
||||||
// ...
|
// ...
|
||||||
});
|
} satisfies IpcRendererFacade);
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("apm_store", {
|
contextBridge.exposeInMainWorld("apm_store", {
|
||||||
arch: (() => {
|
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 ---------
|
// --------- Preload scripts loading ---------
|
||||||
function domReady(
|
function domReady(
|
||||||
condition: DocumentReadyState[] = ["complete", "interactive"],
|
condition: DocumentReadyState[] = ["complete", "interactive"],
|
||||||
|
|||||||
169
src/App.vue
169
src/App.vue
@@ -60,6 +60,7 @@
|
|||||||
<AppGrid
|
<AppGrid
|
||||||
:apps="filteredApps"
|
:apps="filteredApps"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
:scroll-key="activeCategory"
|
||||||
:store-filter="storeFilter"
|
:store-filter="storeFilter"
|
||||||
@open-detail="openDetail"
|
@open-detail="openDetail"
|
||||||
/>
|
/>
|
||||||
@@ -126,17 +127,18 @@
|
|||||||
@switch-origin="handleSwitchOrigin"
|
@switch-origin="handleSwitchOrigin"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UpdateAppsModal
|
<UpdateCenterModal
|
||||||
:show="showUpdateModal"
|
:show="updateCenterStore.isOpen.value"
|
||||||
:apps="upgradableApps"
|
:store="updateCenterStore"
|
||||||
:loading="updateLoading"
|
@update:search-query="updateCenterStore.searchQuery.value = $event"
|
||||||
:error="updateError"
|
@toggle-selection="updateCenterStore.toggleSelection"
|
||||||
:has-selected="hasSelectedUpgrades"
|
@request-start-selected="handleStartSelectedUpdates"
|
||||||
@close="closeUpdateModal"
|
@confirm-migration-start="confirmMigrationStart"
|
||||||
@refresh="refreshUpgradableApps"
|
@dismiss-migration-confirm="
|
||||||
@toggle-all="toggleAllUpgrades"
|
updateCenterStore.showMigrationConfirm.value = false
|
||||||
@upgrade-selected="upgradeSelectedApps"
|
"
|
||||||
@upgrade-one="upgradeSingleApp"
|
@confirm-close="updateCenterStore.closeNow()"
|
||||||
|
@dismiss-close-confirm="updateCenterStore.showCloseConfirm.value = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UninstallConfirmModal
|
<UninstallConfirmModal
|
||||||
@@ -151,7 +153,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch, nextTick } from "vue";
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import AppSidebar from "./components/AppSidebar.vue";
|
import AppSidebar from "./components/AppSidebar.vue";
|
||||||
@@ -163,7 +165,7 @@ import ScreenPreview from "./components/ScreenPreview.vue";
|
|||||||
import DownloadQueue from "./components/DownloadQueue.vue";
|
import DownloadQueue from "./components/DownloadQueue.vue";
|
||||||
import DownloadDetail from "./components/DownloadDetail.vue";
|
import DownloadDetail from "./components/DownloadDetail.vue";
|
||||||
import InstalledAppsModal from "./components/InstalledAppsModal.vue";
|
import InstalledAppsModal from "./components/InstalledAppsModal.vue";
|
||||||
import UpdateAppsModal from "./components/UpdateAppsModal.vue";
|
import UpdateCenterModal from "./components/UpdateCenterModal.vue";
|
||||||
import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
|
import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
|
||||||
import AboutModal from "./components/AboutModal.vue";
|
import AboutModal from "./components/AboutModal.vue";
|
||||||
import {
|
import {
|
||||||
@@ -180,20 +182,17 @@ import {
|
|||||||
removeDownloadItem,
|
removeDownloadItem,
|
||||||
watchDownloadsChange,
|
watchDownloadsChange,
|
||||||
} from "./global/downloadStatus";
|
} from "./global/downloadStatus";
|
||||||
import {
|
import { handleInstall, handleRetry } from "./modules/processInstall";
|
||||||
handleInstall,
|
import { createUpdateCenterStore } from "./modules/updateCenter";
|
||||||
handleRetry,
|
|
||||||
handleUpgrade,
|
|
||||||
} from "./modules/processInstall";
|
|
||||||
import type {
|
import type {
|
||||||
App,
|
App,
|
||||||
AppJson,
|
AppJson,
|
||||||
DownloadItem,
|
DownloadItem,
|
||||||
UpdateAppItem,
|
|
||||||
ChannelPayload,
|
ChannelPayload,
|
||||||
CategoryInfo,
|
CategoryInfo,
|
||||||
HomeLink,
|
HomeLink,
|
||||||
HomeList,
|
HomeList,
|
||||||
|
UpdateCenterItem,
|
||||||
} from "./global/typedefinition";
|
} from "./global/typedefinition";
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import type { IpcRendererEvent } from "electron";
|
import type { IpcRendererEvent } from "electron";
|
||||||
@@ -251,12 +250,7 @@ const activeInstalledOrigin = ref<"apm" | "spark">("apm");
|
|||||||
const installedApps = ref<App[]>([]);
|
const installedApps = ref<App[]>([]);
|
||||||
const installedLoading = ref(false);
|
const installedLoading = ref(false);
|
||||||
const installedError = ref("");
|
const installedError = ref("");
|
||||||
const showUpdateModal = ref(false);
|
const updateCenterStore = createUpdateCenterStore();
|
||||||
const upgradableApps = ref<(App & { selected: boolean; upgrading: boolean })[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const updateLoading = ref(false);
|
|
||||||
const updateError = ref("");
|
|
||||||
const showUninstallModal = ref(false);
|
const showUninstallModal = ref(false);
|
||||||
const uninstallTargetApp: Ref<App | null> = ref(null);
|
const uninstallTargetApp: Ref<App | null> = ref(null);
|
||||||
const showAboutModal = ref(false);
|
const showAboutModal = ref(false);
|
||||||
@@ -345,10 +339,6 @@ const categoryCounts = computed(() => {
|
|||||||
return counts;
|
return counts;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasSelectedUpgrades = computed(() => {
|
|
||||||
return upgradableApps.value.some((app) => app.selected);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
const syncThemePreference = () => {
|
const syncThemePreference = () => {
|
||||||
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
||||||
@@ -568,7 +558,9 @@ const openDetail = async (app: App | Record<string, unknown>) => {
|
|||||||
finalApp.viewingOrigin = "apm";
|
finalApp.viewingOrigin = "apm";
|
||||||
} else {
|
} else {
|
||||||
// 若都安装或都未安装,根据优先级配置决定默认展示
|
// 若都安装或都未安装,根据优先级配置决定默认展示
|
||||||
finalApp.viewingOrigin = getHybridDefaultOrigin(finalApp.sparkApp || finalApp);
|
finalApp.viewingOrigin = getHybridDefaultOrigin(
|
||||||
|
finalApp.sparkApp || finalApp,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,14 +761,7 @@ const nextScreen = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
try {
|
await openUpdateModal();
|
||||||
const result = await window.ipcRenderer.invoke("run-update-tool");
|
|
||||||
if (!result || !result.success) {
|
|
||||||
logger.warn(`启动更新工具失败: ${result?.message || "未知错误"}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`调用更新工具时出错: ${error}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenInstallSettings = async () => {
|
const handleOpenInstallSettings = async () => {
|
||||||
@@ -794,94 +779,35 @@ const handleList = () => {
|
|||||||
openInstalledModal();
|
openInstalledModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openUpdateModal = () => {
|
const openUpdateModal = async () => {
|
||||||
showUpdateModal.value = true;
|
|
||||||
refreshUpgradableApps();
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeUpdateModal = () => {
|
|
||||||
showUpdateModal.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshUpgradableApps = async () => {
|
|
||||||
updateLoading.value = true;
|
|
||||||
updateError.value = "";
|
|
||||||
try {
|
try {
|
||||||
const result = await window.ipcRenderer.invoke("list-upgradable");
|
await updateCenterStore.open();
|
||||||
if (!result?.success) {
|
} catch (error) {
|
||||||
upgradableApps.value = [];
|
logger.error(`打开更新中心失败: ${error}`);
|
||||||
updateError.value = result?.message || "检查更新失败";
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMigrationSelection = (items: UpdateCenterItem[]): boolean => {
|
||||||
|
return items.some((item) => item.isMigration === true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartSelectedUpdates = async () => {
|
||||||
|
const selectedItems = updateCenterStore.getSelectedItems();
|
||||||
|
if (selectedItems.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
upgradableApps.value = (result.apps || []).map(
|
if (hasMigrationSelection(selectedItems)) {
|
||||||
(app: Record<string, string>) => ({
|
updateCenterStore.showMigrationConfirm.value = true;
|
||||||
...app,
|
return;
|
||||||
// Map properties if needed or assume main matches App interface except field names might differ
|
|
||||||
// For now assuming result.apps returns objects compatible with App for core fields,
|
|
||||||
// but let's normalize just in case if main returns different structure.
|
|
||||||
name: app.name || app.Name || "",
|
|
||||||
pkgname: app.pkgname || app.Pkgname || "",
|
|
||||||
version: app.newVersion || app.version || "",
|
|
||||||
category: app.category || "unknown",
|
|
||||||
selected: false,
|
|
||||||
upgrading: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
upgradableApps.value = [];
|
|
||||||
updateError.value = (error as Error)?.message || "检查更新失败";
|
|
||||||
} finally {
|
|
||||||
updateLoading.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await updateCenterStore.startSelected();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAllUpgrades = () => {
|
const confirmMigrationStart = async () => {
|
||||||
const shouldSelectAll =
|
updateCenterStore.showMigrationConfirm.value = false;
|
||||||
!hasSelectedUpgrades.value ||
|
await updateCenterStore.startSelected();
|
||||||
upgradableApps.value.some((app) => !app.selected);
|
|
||||||
upgradableApps.value = upgradableApps.value.map((app) => ({
|
|
||||||
...app,
|
|
||||||
selected: shouldSelectAll ? true : false,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const upgradeSingleApp = async (app: UpdateAppItem) => {
|
|
||||||
if (!app?.pkgname) return;
|
|
||||||
const target = apps.value.find((a) => a.pkgname === app.pkgname);
|
|
||||||
if (target) {
|
|
||||||
await handleUpgrade(target);
|
|
||||||
} else {
|
|
||||||
// If we can't find it in the list (e.g. category not loaded?), use the info we have
|
|
||||||
// But handleUpgrade expects App. Let's try to construct minimal App
|
|
||||||
let minimalApp: App = {
|
|
||||||
name: app.pkgname,
|
|
||||||
pkgname: app.pkgname,
|
|
||||||
version: app.newVersion || "",
|
|
||||||
category: "unknown",
|
|
||||||
tags: "",
|
|
||||||
more: "",
|
|
||||||
filename: "",
|
|
||||||
torrent_address: "",
|
|
||||||
author: "",
|
|
||||||
contributor: "",
|
|
||||||
website: "",
|
|
||||||
update: "",
|
|
||||||
size: "",
|
|
||||||
img_urls: [],
|
|
||||||
icons: "",
|
|
||||||
origin: "apm", // Default to APM if unknown, or try to guess
|
|
||||||
currentStatus: "installed",
|
|
||||||
};
|
|
||||||
await handleUpgrade(minimalApp);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const upgradeSelectedApps = async () => {
|
|
||||||
const selectedApps = upgradableApps.value.filter((app) => app.selected);
|
|
||||||
for (const app of selectedApps) {
|
|
||||||
await upgradeSingleApp(app);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openInstalledModal = () => {
|
const openInstalledModal = () => {
|
||||||
@@ -1224,6 +1150,7 @@ const handleSearchFocus = () => {
|
|||||||
// 生命周期钩子
|
// 生命周期钩子
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
initTheme();
|
initTheme();
|
||||||
|
updateCenterStore.bind();
|
||||||
|
|
||||||
// 从主进程获取启动参数(--no-apm / --no-spark),再加载数据
|
// 从主进程获取启动参数(--no-apm / --no-spark),再加载数据
|
||||||
storeFilter.value = await window.ipcRenderer.invoke("get-store-filter");
|
storeFilter.value = await window.ipcRenderer.invoke("get-store-filter");
|
||||||
@@ -1365,6 +1292,10 @@ onMounted(async () => {
|
|||||||
logger.info("Renderer process is ready!");
|
logger.info("Renderer process is ready!");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
updateCenterStore.unbind();
|
||||||
|
});
|
||||||
|
|
||||||
// 观察器
|
// 观察器
|
||||||
watch(themeMode, (newVal) => {
|
watch(themeMode, (newVal) => {
|
||||||
localStorage.setItem("theme", newVal);
|
localStorage.setItem("theme", newVal);
|
||||||
|
|||||||
101
src/__tests__/unit/AppGrid.test.ts
Normal file
101
src/__tests__/unit/AppGrid.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { render } from "@testing-library/vue";
|
||||||
|
import { defineComponent, h, nextTick } from "vue";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import AppGrid from "@/components/AppGrid.vue";
|
||||||
|
import type { App } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
vi.mock("@/components/AppCard.vue", () => ({
|
||||||
|
default: defineComponent({
|
||||||
|
name: "AppCard",
|
||||||
|
props: {
|
||||||
|
app: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => h("div", props.app.name);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("vue-virtual-scroller", () => ({
|
||||||
|
RecycleScroller: defineComponent({
|
||||||
|
name: "RecycleScroller",
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { attrs, slots }) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
...attrs,
|
||||||
|
style: "max-height: 320px; overflow-y: auto;",
|
||||||
|
},
|
||||||
|
(props.items as Array<{ id: number; apps: App[] }>).map((item) =>
|
||||||
|
slots.default?.({ item }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createApp = (index: number, category: string): App => ({
|
||||||
|
name: `App ${index}`,
|
||||||
|
pkgname: `app-${category}-${index}`,
|
||||||
|
version: "1.0.0",
|
||||||
|
filename: "app.deb",
|
||||||
|
torrent_address: "",
|
||||||
|
author: "",
|
||||||
|
contributor: "",
|
||||||
|
website: "",
|
||||||
|
update: "",
|
||||||
|
size: "1 MB",
|
||||||
|
more: "",
|
||||||
|
tags: "",
|
||||||
|
img_urls: [],
|
||||||
|
icons: "",
|
||||||
|
category,
|
||||||
|
origin: "spark",
|
||||||
|
currentStatus: "not-installed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const createApps = (count: number, category: string): App[] =>
|
||||||
|
Array.from({ length: count }, (_, index) => createApp(index, category));
|
||||||
|
|
||||||
|
describe("AppGrid", () => {
|
||||||
|
it("resets the virtual scroller when the category changes", async () => {
|
||||||
|
const { container, rerender } = render(AppGrid, {
|
||||||
|
props: {
|
||||||
|
apps: createApps(60, "development"),
|
||||||
|
loading: false,
|
||||||
|
scrollKey: "development",
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scroller = container.querySelector(".scroller");
|
||||||
|
|
||||||
|
expect(scroller).toBeInstanceOf(HTMLElement);
|
||||||
|
|
||||||
|
if (!(scroller instanceof HTMLElement)) {
|
||||||
|
throw new Error("Expected virtual scroller element to exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
scroller.scrollTop = 240;
|
||||||
|
expect(scroller.scrollTop).toBe(240);
|
||||||
|
|
||||||
|
await rerender({
|
||||||
|
apps: createApps(60, "games"),
|
||||||
|
loading: false,
|
||||||
|
scrollKey: "games",
|
||||||
|
} as Record<string, unknown>);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(scroller.scrollTop).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
182
src/__tests__/unit/update-center/UpdateCenterModal.test.ts
Normal file
182
src/__tests__/unit/update-center/UpdateCenterModal.test.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import UpdateCenterModal from "@/components/UpdateCenterModal.vue";
|
||||||
|
import type {
|
||||||
|
UpdateCenterItem,
|
||||||
|
UpdateCenterSnapshot,
|
||||||
|
UpdateCenterTaskState,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
import type { UpdateCenterStore } from "@/modules/updateCenter";
|
||||||
|
|
||||||
|
const createItem = (
|
||||||
|
overrides: Partial<UpdateCenterItem> = {},
|
||||||
|
): UpdateCenterItem => ({
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
packageName: "spark-weather",
|
||||||
|
displayName: "Spark Weather",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
source: "aptss",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTask = (
|
||||||
|
overrides: Partial<UpdateCenterTaskState> = {},
|
||||||
|
): UpdateCenterTaskState => ({
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
packageName: "spark-weather",
|
||||||
|
source: "aptss",
|
||||||
|
status: "downloading",
|
||||||
|
progress: 42,
|
||||||
|
logs: [],
|
||||||
|
errorMessage: "",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStore = (
|
||||||
|
overrides: Partial<UpdateCenterSnapshot> = {},
|
||||||
|
): UpdateCenterStore => {
|
||||||
|
const snapshot = ref<UpdateCenterSnapshot>({
|
||||||
|
items: [
|
||||||
|
createItem({
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
source: "aptss",
|
||||||
|
}),
|
||||||
|
createItem({
|
||||||
|
taskKey: "apm:spark-clock",
|
||||||
|
packageName: "spark-clock",
|
||||||
|
displayName: "Spark Clock",
|
||||||
|
source: "apm",
|
||||||
|
isMigration: true,
|
||||||
|
migrationTarget: "apm",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
tasks: [createTask()],
|
||||||
|
warnings: ["更新过程中请勿关闭商店"],
|
||||||
|
hasRunningTasks: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedTaskKeys = ref(new Set<string>(["aptss:spark-weather"]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen: ref(true),
|
||||||
|
showCloseConfirm: ref(true),
|
||||||
|
showMigrationConfirm: ref(false),
|
||||||
|
searchQuery: ref(""),
|
||||||
|
selectedTaskKeys,
|
||||||
|
snapshot,
|
||||||
|
filteredItems: computed(() => snapshot.value.items),
|
||||||
|
bind: vi.fn(),
|
||||||
|
unbind: vi.fn(),
|
||||||
|
open: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
toggleSelection: vi.fn(),
|
||||||
|
getSelectedItems: vi.fn(() =>
|
||||||
|
snapshot.value.items.filter(
|
||||||
|
(item) =>
|
||||||
|
selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
closeNow: vi.fn(),
|
||||||
|
startSelected: vi.fn(),
|
||||||
|
requestClose: vi.fn(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("UpdateCenterModal", () => {
|
||||||
|
it("renders source tags, running state, warnings, migration marker, and close confirmation", () => {
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
render(UpdateCenterModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
store,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("软件更新")).toBeTruthy();
|
||||||
|
expect(screen.getByText("传统deb")).toBeTruthy();
|
||||||
|
expect(screen.getByText("APM")).toBeTruthy();
|
||||||
|
expect(screen.getByText("将迁移到 APM")).toBeTruthy();
|
||||||
|
expect(screen.getByText("更新过程中请勿关闭商店")).toBeTruthy();
|
||||||
|
expect(screen.getByText("下载中")).toBeTruthy();
|
||||||
|
expect(screen.getByText("42%")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/确定关闭/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close confirmation exposes a confirm-close path", async () => {
|
||||||
|
const onConfirmClose = vi.fn();
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
render(UpdateCenterModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
store,
|
||||||
|
onConfirmClose,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "确认关闭" }));
|
||||||
|
|
||||||
|
expect(onConfirmClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ignored items as disabled instead of normal selectable actions", () => {
|
||||||
|
const store = createStore({
|
||||||
|
items: [
|
||||||
|
createItem({
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
packageName: "spark-weather",
|
||||||
|
displayName: "Spark Weather",
|
||||||
|
source: "aptss",
|
||||||
|
ignored: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
tasks: [],
|
||||||
|
warnings: [],
|
||||||
|
hasRunningTasks: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(UpdateCenterModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
store,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("已忽略")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("checkbox")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders migration confirmation when requested", () => {
|
||||||
|
const store = createStore({ hasRunningTasks: false });
|
||||||
|
store.showMigrationConfirm.value = true;
|
||||||
|
|
||||||
|
render(UpdateCenterModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
store,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("迁移确认")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close button triggers request-close flow", async () => {
|
||||||
|
const store = createStore({ hasRunningTasks: false });
|
||||||
|
|
||||||
|
render(UpdateCenterModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
store,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "关闭" }));
|
||||||
|
|
||||||
|
expect(store.requestClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
src/__tests__/unit/update-center/ignore-config.test.ts
Normal file
124
src/__tests__/unit/update-center/ignore-config.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
|
||||||
|
import {
|
||||||
|
LEGACY_IGNORE_CONFIG_PATH,
|
||||||
|
applyIgnoredEntries,
|
||||||
|
createIgnoreKey,
|
||||||
|
loadIgnoredEntries,
|
||||||
|
parseIgnoredEntries,
|
||||||
|
saveIgnoredEntries,
|
||||||
|
} from "../../../../electron/main/backend/update-center/ignore-config";
|
||||||
|
|
||||||
|
describe("update-center ignore config", () => {
|
||||||
|
it("round-trips the legacy package|version format", async () => {
|
||||||
|
expect(LEGACY_IGNORE_CONFIG_PATH).toBe(
|
||||||
|
"/etc/spark-store/ignored_apps.conf",
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = new Set([
|
||||||
|
createIgnoreKey("spark-clock", "2.0.0"),
|
||||||
|
createIgnoreKey("spark-browser", "1.5.0"),
|
||||||
|
]);
|
||||||
|
const tempDir = await mkdtemp(join(tmpdir(), "spark-ignore-config-"));
|
||||||
|
const filePath = join(tempDir, "ignored_apps.conf");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveIgnoredEntries(filePath, entries);
|
||||||
|
|
||||||
|
expect(await readFile(filePath, "utf8")).toBe(
|
||||||
|
"spark-browser|1.5.0\nspark-clock|2.0.0\n",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
parseIgnoredEntries("spark-browser|1.5.0\nspark-clock|2.0.0\n"),
|
||||||
|
).toEqual(new Set(["spark-browser|1.5.0", "spark-clock|2.0.0"]));
|
||||||
|
await expect(loadIgnoredEntries(filePath)).resolves.toEqual(entries);
|
||||||
|
} finally {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores malformed lines and accepts CRLF legacy entries", () => {
|
||||||
|
expect(
|
||||||
|
parseIgnoredEntries(
|
||||||
|
[
|
||||||
|
"spark-browser|1.5.0\r",
|
||||||
|
"spark-clock|2.0.0|extra",
|
||||||
|
"missing-version|",
|
||||||
|
"|missing-package",
|
||||||
|
"spark-player|3.0.0",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
).toEqual(new Set(["spark-browser|1.5.0", "spark-player|3.0.0"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks only exact package/version matches as ignored", () => {
|
||||||
|
const items: UpdateCenterItem[] = [
|
||||||
|
{
|
||||||
|
pkgname: "spark-clock",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pkgname: "spark-clock",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1.1.0",
|
||||||
|
nextVersion: "2.1.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pkgname: "spark-browser",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "4.0.0",
|
||||||
|
nextVersion: "5.0.0",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyIgnoredEntries(
|
||||||
|
items,
|
||||||
|
new Set([createIgnoreKey("spark-clock", "2.0.0")]),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-clock",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
ignored: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pkgname: "spark-clock",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1.1.0",
|
||||||
|
nextVersion: "2.1.0",
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pkgname: "spark-browser",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "4.0.0",
|
||||||
|
nextVersion: "5.0.0",
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing file returns an empty set", async () => {
|
||||||
|
const tempDir = await mkdtemp(
|
||||||
|
join(tmpdir(), "spark-ignore-config-missing-"),
|
||||||
|
);
|
||||||
|
const filePath = join(tempDir, "missing.conf");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(loadIgnoredEntries(filePath)).resolves.toEqual(new Set());
|
||||||
|
} finally {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
137
src/__tests__/unit/update-center/load-items.test.ts
Normal file
137
src/__tests__/unit/update-center/load-items.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { loadUpdateCenterItems } from "../../../../electron/main/backend/update-center";
|
||||||
|
|
||||||
|
interface CommandResult {
|
||||||
|
code: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APTSS_LIST_UPGRADABLE_KEY =
|
||||||
|
"bash -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_KEY =
|
||||||
|
"dpkg-query -W -f=${Package}\t${db:Status-Want} ${db:Status-Status} ${db:Status-Eflag}\n";
|
||||||
|
|
||||||
|
const APM_PRINT_URIS_KEY =
|
||||||
|
"bash -lc amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download spark-weather --print-uris";
|
||||||
|
|
||||||
|
describe("update-center load items", () => {
|
||||||
|
it("enriches apm and migration items with download metadata needed by the runner", async () => {
|
||||||
|
const commandResults = new Map<string, CommandResult>([
|
||||||
|
[
|
||||||
|
APTSS_LIST_UPGRADABLE_KEY,
|
||||||
|
{
|
||||||
|
code: 0,
|
||||||
|
stdout: "spark-weather/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
|
||||||
|
stderr: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"apm list --upgradable",
|
||||||
|
{
|
||||||
|
code: 0,
|
||||||
|
stdout: "spark-weather/main 3.0.0 amd64 [upgradable from: 1.5.0]",
|
||||||
|
stderr: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
DPKG_QUERY_INSTALLED_KEY,
|
||||||
|
{
|
||||||
|
code: 0,
|
||||||
|
stdout: "spark-weather\tinstall ok installed\n",
|
||||||
|
stderr: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"apm list --installed",
|
||||||
|
{
|
||||||
|
code: 0,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
APM_PRINT_URIS_KEY,
|
||||||
|
{
|
||||||
|
code: 0,
|
||||||
|
stdout:
|
||||||
|
"'https://example.invalid/spark-weather_3.0.0_amd64.deb' spark-weather_3.0.0_amd64.deb 123456 SHA512:deadbeef",
|
||||||
|
stderr: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await loadUpdateCenterItems(async (command, args) => {
|
||||||
|
const key = `${command} ${args.join(" ")}`;
|
||||||
|
const match = commandResults.get(key);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Missing mock for ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.warnings).toEqual([]);
|
||||||
|
expect(result.items).toContainEqual({
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1.5.0",
|
||||||
|
nextVersion: "3.0.0",
|
||||||
|
downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb",
|
||||||
|
fileName: "spark-weather_3.0.0_amd64.deb",
|
||||||
|
size: 123456,
|
||||||
|
sha512: "deadbeef",
|
||||||
|
isMigration: true,
|
||||||
|
migrationSource: "aptss",
|
||||||
|
migrationTarget: "apm",
|
||||||
|
aptssVersion: "2.0.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades to aptss-only results when apm commands fail", async () => {
|
||||||
|
const result = await loadUpdateCenterItems(async (command, args) => {
|
||||||
|
const key = `${command} ${args.join(" ")}`;
|
||||||
|
|
||||||
|
if (key === APTSS_LIST_UPGRADABLE_KEY) {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
stdout: "spark-notes/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === DPKG_QUERY_INSTALLED_KEY) {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
stdout: "spark-notes\tinstall ok installed\n",
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "apm list --upgradable" || key === "apm list --installed") {
|
||||||
|
return {
|
||||||
|
code: 127,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "apm: command not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected command ${key}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-notes",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.warnings).toEqual([
|
||||||
|
"apm upgradable query failed: apm: command not found",
|
||||||
|
"apm installed query failed: apm: command not found",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getMainWindowCloseAction,
|
||||||
|
shouldPreventMainWindowClose,
|
||||||
|
} from "../../../../electron/main/window-close-guard";
|
||||||
|
|
||||||
|
describe("main window close guard", () => {
|
||||||
|
it("keeps the app alive while update-center work is running", () => {
|
||||||
|
expect(
|
||||||
|
shouldPreventMainWindowClose({
|
||||||
|
installTaskCount: 0,
|
||||||
|
hasRunningUpdateCenterTasks: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows close only when both install and update-center work are idle", () => {
|
||||||
|
expect(
|
||||||
|
shouldPreventMainWindowClose({
|
||||||
|
installTaskCount: 0,
|
||||||
|
hasRunningUpdateCenterTasks: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a hide action while any guarded work is active", () => {
|
||||||
|
expect(
|
||||||
|
getMainWindowCloseAction({
|
||||||
|
installTaskCount: 1,
|
||||||
|
hasRunningUpdateCenterTasks: false,
|
||||||
|
}),
|
||||||
|
).toBe("hide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a destroy action only when all guarded work is idle", () => {
|
||||||
|
expect(
|
||||||
|
getMainWindowCloseAction({
|
||||||
|
installTaskCount: 0,
|
||||||
|
hasRunningUpdateCenterTasks: false,
|
||||||
|
}),
|
||||||
|
).toBe("destroy");
|
||||||
|
});
|
||||||
|
});
|
||||||
294
src/__tests__/unit/update-center/query.test.ts
Normal file
294
src/__tests__/unit/update-center/query.test.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildInstalledSourceMap,
|
||||||
|
mergeUpdateSources,
|
||||||
|
parseApmUpgradableOutput,
|
||||||
|
parseAptssUpgradableOutput,
|
||||||
|
parsePrintUrisOutput,
|
||||||
|
} from "../../../../electron/main/backend/update-center/query";
|
||||||
|
|
||||||
|
describe("update-center query", () => {
|
||||||
|
it("parses aptss upgradable output into normalized aptss items", () => {
|
||||||
|
const output = [
|
||||||
|
"Listing...",
|
||||||
|
"spark-weather/stable 2.0.0 amd64 [upgradable from: 1.9.0]",
|
||||||
|
"spark-same/stable 1.0.0 amd64 [upgradable from: 1.0.0]",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(parseAptssUpgradableOutput(output)).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.9.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the legacy from variant in upgradable output", () => {
|
||||||
|
const aptssOutput = "spark-clock/stable 1.2.0 amd64 [from: 1.1.0]";
|
||||||
|
const apmOutput = "spark-player/main 2.0.0 amd64 [from: 1.5.0]";
|
||||||
|
|
||||||
|
expect(parseAptssUpgradableOutput(aptssOutput)).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-clock",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.1.0",
|
||||||
|
nextVersion: "1.2.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(parseApmUpgradableOutput(apmOutput)).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-player",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1.5.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses apt print-uris output into download metadata", () => {
|
||||||
|
const output =
|
||||||
|
"'https://example.invalid/pool/main/s/spark-weather_2.0.0_amd64.deb' spark-weather_2.0.0_amd64.deb 123456 SHA512:deadbeef";
|
||||||
|
|
||||||
|
expect(parsePrintUrisOutput(output)).toEqual({
|
||||||
|
downloadUrl:
|
||||||
|
"https://example.invalid/pool/main/s/spark-weather_2.0.0_amd64.deb",
|
||||||
|
fileName: "spark-weather_2.0.0_amd64.deb",
|
||||||
|
size: 123456,
|
||||||
|
sha512: "deadbeef",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an apm item as migration when the same package is only installed in aptss", () => {
|
||||||
|
const merged = mergeUpdateSources(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.9.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1.8.0",
|
||||||
|
nextVersion: "3.0.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
new Map([["spark-weather", { aptss: true, apm: false }]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1.8.0",
|
||||||
|
nextVersion: "3.0.0",
|
||||||
|
isMigration: true,
|
||||||
|
migrationSource: "aptss",
|
||||||
|
migrationTarget: "apm",
|
||||||
|
aptssVersion: "2.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.9.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Debian-style version ordering for migration decisions", () => {
|
||||||
|
const merged = mergeUpdateSources(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
pkgname: "spark-browser",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "9.0",
|
||||||
|
nextVersion: "10.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
pkgname: "spark-browser",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1:0.9",
|
||||||
|
nextVersion: "2:1.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
new Map([["spark-browser", { aptss: true, apm: false }]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged[0]).toMatchObject({
|
||||||
|
pkgname: "spark-browser",
|
||||||
|
source: "apm",
|
||||||
|
isMigration: true,
|
||||||
|
aptssVersion: "10.0",
|
||||||
|
});
|
||||||
|
expect(merged[1]).toMatchObject({
|
||||||
|
pkgname: "spark-browser",
|
||||||
|
source: "aptss",
|
||||||
|
nextVersion: "10.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Debian epoch ordering in the fallback when dpkg is unavailable", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("node:child_process", async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import("node:child_process")>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: actual,
|
||||||
|
spawnSync: vi.fn(() => ({
|
||||||
|
status: null,
|
||||||
|
error: new Error("dpkg unavailable"),
|
||||||
|
output: null,
|
||||||
|
pid: 0,
|
||||||
|
signal: null,
|
||||||
|
stdout: Buffer.alloc(0),
|
||||||
|
stderr: Buffer.alloc(0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mergeUpdateSources: mergeWithFallback } =
|
||||||
|
await import("../../../../electron/main/backend/update-center/query");
|
||||||
|
|
||||||
|
const merged = mergeWithFallback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
pkgname: "spark-reader",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "2.5.0",
|
||||||
|
nextVersion: "2.9.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
pkgname: "spark-reader",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "2.0.0",
|
||||||
|
nextVersion: "3.0.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
new Map([["spark-reader", { aptss: true, apm: false }]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged[0]).toMatchObject({
|
||||||
|
pkgname: "spark-reader",
|
||||||
|
source: "apm",
|
||||||
|
isMigration: true,
|
||||||
|
aptssVersion: "2.9.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doUnmock("node:child_process");
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Debian tilde ordering in the fallback when dpkg is unavailable", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("node:child_process", async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import("node:child_process")>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: actual,
|
||||||
|
spawnSync: vi.fn(() => ({
|
||||||
|
status: null,
|
||||||
|
error: new Error("dpkg unavailable"),
|
||||||
|
output: null,
|
||||||
|
pid: 0,
|
||||||
|
signal: null,
|
||||||
|
stdout: Buffer.alloc(0),
|
||||||
|
stderr: Buffer.alloc(0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mergeUpdateSources: mergeWithFallback } =
|
||||||
|
await import("../../../../electron/main/backend/update-center/query");
|
||||||
|
|
||||||
|
const merged = mergeWithFallback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
pkgname: "spark-tilde",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "0.9",
|
||||||
|
nextVersion: "1.0~rc1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
pkgname: "spark-tilde",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "0.9",
|
||||||
|
nextVersion: "1.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
new Map([["spark-tilde", { aptss: true, apm: false }]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged[0]).toMatchObject({
|
||||||
|
pkgname: "spark-tilde",
|
||||||
|
source: "apm",
|
||||||
|
isMigration: true,
|
||||||
|
aptssVersion: "1.0~rc1",
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doUnmock("node:child_process");
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses apm list output into normalized apm items", () => {
|
||||||
|
const output = [
|
||||||
|
"Listing...",
|
||||||
|
"spark-music/main 5.0.0 arm64 [upgradable from: 4.5.0]",
|
||||||
|
"spark-same/main 1.0.0 arm64 [upgradable from: 1.0.0]",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(parseApmUpgradableOutput(output)).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-music",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "4.5.0",
|
||||||
|
nextVersion: "5.0.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds installed-source map from dpkg-query and apm list output", () => {
|
||||||
|
const dpkgOutput = [
|
||||||
|
"spark-weather\tinstall ok installed",
|
||||||
|
"spark-weather-data\tdeinstall ok config-files",
|
||||||
|
"spark-notes\tinstall ok installed",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const apmInstalledOutput = [
|
||||||
|
"Listing...",
|
||||||
|
"spark-weather/main,stable 3.0.0 amd64 [installed]",
|
||||||
|
"spark-player/main 1.0.0 amd64 [installed,automatic]",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Array.from(
|
||||||
|
buildInstalledSourceMap(dpkgOutput, apmInstalledOutput).entries(),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
["spark-weather", { aptss: true, apm: true }],
|
||||||
|
["spark-notes", { aptss: true, apm: false }],
|
||||||
|
["spark-player", { aptss: false, apm: true }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
555
src/__tests__/unit/update-center/registerUpdateCenter.test.ts
Normal file
555
src/__tests__/unit/update-center/registerUpdateCenter.test.ts
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
|
||||||
|
import type { UpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
|
||||||
|
import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
|
||||||
|
import {
|
||||||
|
createUpdateCenterService,
|
||||||
|
type UpdateCenterServiceState,
|
||||||
|
} from "../../../../electron/main/backend/update-center/service";
|
||||||
|
import { registerUpdateCenterIpc } from "../../../../electron/main/backend/update-center";
|
||||||
|
|
||||||
|
const flushPromises = async (): Promise<void> => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const electronMock = vi.hoisted(() => ({
|
||||||
|
getAllWindows: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
BrowserWindow: {
|
||||||
|
getAllWindows: electronMock.getAllWindows,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createItem = (): UpdateCenterItem => ({
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update-center/ipc", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
electronMock.getAllWindows.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers every update-center handler and forwards service calls", async () => {
|
||||||
|
const handle = vi.fn();
|
||||||
|
const send = vi.fn();
|
||||||
|
const snapshot: UpdateCenterServiceState = {
|
||||||
|
items: [],
|
||||||
|
tasks: [],
|
||||||
|
warnings: [],
|
||||||
|
hasRunningTasks: false,
|
||||||
|
};
|
||||||
|
let listener:
|
||||||
|
| ((nextSnapshot: UpdateCenterServiceState) => void)
|
||||||
|
| undefined;
|
||||||
|
const service = {
|
||||||
|
open: vi.fn().mockResolvedValue(snapshot),
|
||||||
|
refresh: vi.fn().mockResolvedValue(snapshot),
|
||||||
|
ignore: vi.fn().mockResolvedValue(undefined),
|
||||||
|
unignore: vi.fn().mockResolvedValue(undefined),
|
||||||
|
start: vi.fn().mockResolvedValue(undefined),
|
||||||
|
cancel: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getState: vi.fn().mockReturnValue(snapshot),
|
||||||
|
subscribe: vi.fn(
|
||||||
|
(nextListener: (nextSnapshot: UpdateCenterServiceState) => void) => {
|
||||||
|
listener = nextListener;
|
||||||
|
return () => undefined;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
|
||||||
|
|
||||||
|
registerUpdateCenterIpc({ handle }, service);
|
||||||
|
|
||||||
|
expect(handle).toHaveBeenCalledWith(
|
||||||
|
"update-center-open",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(handle).toHaveBeenCalledWith(
|
||||||
|
"update-center-refresh",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(handle).toHaveBeenCalledWith(
|
||||||
|
"update-center-ignore",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(handle).toHaveBeenCalledWith(
|
||||||
|
"update-center-unignore",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(handle).toHaveBeenCalledWith(
|
||||||
|
"update-center-start",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(handle).toHaveBeenCalledWith(
|
||||||
|
"update-center-cancel",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(handle).toHaveBeenCalledWith(
|
||||||
|
"update-center-get-state",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(service.subscribe).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const openHandler = handle.mock.calls.find(
|
||||||
|
([channel]: [string]) => channel === "update-center-open",
|
||||||
|
)?.[1] as
|
||||||
|
| ((event: unknown) => Promise<UpdateCenterServiceState>)
|
||||||
|
| undefined;
|
||||||
|
const refreshHandler = handle.mock.calls.find(
|
||||||
|
([channel]: [string]) => channel === "update-center-refresh",
|
||||||
|
)?.[1] as
|
||||||
|
| ((event: unknown) => Promise<UpdateCenterServiceState>)
|
||||||
|
| undefined;
|
||||||
|
const ignoreHandler = handle.mock.calls.find(
|
||||||
|
([channel]: [string]) => channel === "update-center-ignore",
|
||||||
|
)?.[1] as
|
||||||
|
| ((
|
||||||
|
event: unknown,
|
||||||
|
payload: { packageName: string; newVersion: string },
|
||||||
|
) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
const unignoreHandler = handle.mock.calls.find(
|
||||||
|
([channel]: [string]) => channel === "update-center-unignore",
|
||||||
|
)?.[1] as
|
||||||
|
| ((
|
||||||
|
event: unknown,
|
||||||
|
payload: { packageName: string; newVersion: string },
|
||||||
|
) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
const startHandler = handle.mock.calls.find(
|
||||||
|
([channel]: [string]) => channel === "update-center-start",
|
||||||
|
)?.[1] as
|
||||||
|
| ((event: unknown, taskKeys: string[]) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
const cancelHandler = handle.mock.calls.find(
|
||||||
|
([channel]: [string]) => channel === "update-center-cancel",
|
||||||
|
)?.[1] as ((event: unknown, taskKey: string) => Promise<void>) | undefined;
|
||||||
|
const getStateHandler = handle.mock.calls.find(
|
||||||
|
([channel]: [string]) => channel === "update-center-get-state",
|
||||||
|
)?.[1] as (() => UpdateCenterServiceState) | undefined;
|
||||||
|
|
||||||
|
await openHandler?.({});
|
||||||
|
await refreshHandler?.({});
|
||||||
|
await ignoreHandler?.(
|
||||||
|
{},
|
||||||
|
{ packageName: "spark-weather", newVersion: "2.0.0" },
|
||||||
|
);
|
||||||
|
await unignoreHandler?.(
|
||||||
|
{},
|
||||||
|
{ packageName: "spark-weather", newVersion: "2.0.0" },
|
||||||
|
);
|
||||||
|
await startHandler?.({}, ["aptss:spark-weather"]);
|
||||||
|
await cancelHandler?.({}, "aptss:spark-weather");
|
||||||
|
|
||||||
|
expect(getStateHandler?.()).toEqual(snapshot);
|
||||||
|
expect(service.open).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.refresh).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.ignore).toHaveBeenCalledWith({
|
||||||
|
packageName: "spark-weather",
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
});
|
||||||
|
expect(service.unignore).toHaveBeenCalledWith({
|
||||||
|
packageName: "spark-weather",
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
});
|
||||||
|
expect(service.start).toHaveBeenCalledWith(["aptss:spark-weather"]);
|
||||||
|
expect(service.cancel).toHaveBeenCalledWith("aptss:spark-weather");
|
||||||
|
|
||||||
|
listener?.(snapshot);
|
||||||
|
expect(send).toHaveBeenCalledWith("update-center-state", snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("service subscribers receive state updates after refresh start and ignore", async () => {
|
||||||
|
let ignoredEntries = new Set<string>();
|
||||||
|
const service = createUpdateCenterService({
|
||||||
|
loadItems: async () => [createItem()],
|
||||||
|
loadIgnoredEntries: async () => new Set(ignoredEntries),
|
||||||
|
saveIgnoredEntries: async (entries: ReadonlySet<string>) => {
|
||||||
|
ignoredEntries = new Set(entries);
|
||||||
|
},
|
||||||
|
createTaskRunner: (queue: UpdateCenterQueue) => ({
|
||||||
|
cancelActiveTask: vi.fn(),
|
||||||
|
runNextTask: async () => {
|
||||||
|
const task = queue.getNextQueuedTask();
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.markActiveTask(task.id, "installing");
|
||||||
|
queue.finishTask(task.id, "completed");
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const snapshots: UpdateCenterServiceState[] = [];
|
||||||
|
|
||||||
|
service.subscribe((snapshot: UpdateCenterServiceState) => {
|
||||||
|
snapshots.push(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.refresh();
|
||||||
|
await service.start(["aptss:spark-weather"]);
|
||||||
|
await service.ignore({ packageName: "spark-weather", newVersion: "2.0.0" });
|
||||||
|
|
||||||
|
expect(snapshots.some((snapshot) => snapshot.hasRunningTasks)).toBe(true);
|
||||||
|
expect(
|
||||||
|
snapshots.some((snapshot) =>
|
||||||
|
snapshot.tasks.some(
|
||||||
|
(task: UpdateCenterServiceState["tasks"][number]) =>
|
||||||
|
task.taskKey === "aptss:spark-weather" &&
|
||||||
|
task.status === "completed",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(snapshots.at(-1)?.items[0]).toMatchObject({
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
ignored: true,
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
});
|
||||||
|
expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("concurrent start calls still serialize through one processing pipeline", async () => {
|
||||||
|
const startedTaskIds: number[] = [];
|
||||||
|
const releases: Array<() => void> = [];
|
||||||
|
const service = createUpdateCenterService({
|
||||||
|
loadItems: async () => [
|
||||||
|
createItem(),
|
||||||
|
{ ...createItem(), pkgname: "spark-clock" },
|
||||||
|
],
|
||||||
|
createTaskRunner: (queue: UpdateCenterQueue) => ({
|
||||||
|
cancelActiveTask: vi.fn(),
|
||||||
|
runNextTask: async () => {
|
||||||
|
const task = queue.getNextQueuedTask();
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startedTaskIds.push(task.id);
|
||||||
|
queue.markActiveTask(task.id, "installing");
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releases.push(resolve);
|
||||||
|
});
|
||||||
|
queue.finishTask(task.id, "completed");
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.refresh();
|
||||||
|
|
||||||
|
const firstStart = service.start(["aptss:spark-weather"]);
|
||||||
|
const secondStart = service.start(["aptss:spark-clock"]);
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
expect(startedTaskIds).toEqual([1]);
|
||||||
|
|
||||||
|
releases.shift()?.();
|
||||||
|
await flushPromises();
|
||||||
|
expect(startedTaskIds).toEqual([1, 2]);
|
||||||
|
|
||||||
|
releases.shift()?.();
|
||||||
|
await Promise.all([firstStart, secondStart]);
|
||||||
|
|
||||||
|
expect(service.getState().tasks).toMatchObject([
|
||||||
|
{ taskKey: "aptss:spark-weather", status: "completed" },
|
||||||
|
{ taskKey: "aptss:spark-clock", status: "completed" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancelling an active task stops it and leaves it cancelled", async () => {
|
||||||
|
let releaseTask: (() => void) | undefined;
|
||||||
|
const cancelActiveTask = vi.fn(() => {
|
||||||
|
releaseTask?.();
|
||||||
|
});
|
||||||
|
const service = createUpdateCenterService({
|
||||||
|
loadItems: async () => [createItem()],
|
||||||
|
createTaskRunner: (queue: UpdateCenterQueue) => ({
|
||||||
|
cancelActiveTask,
|
||||||
|
runNextTask: async () => {
|
||||||
|
const task = queue.getNextQueuedTask();
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.markActiveTask(task.id, "installing");
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releaseTask = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
queue.getSnapshot().tasks.find((entry) => entry.id === task.id) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.refresh();
|
||||||
|
|
||||||
|
const startPromise = service.start(["aptss:spark-weather"]);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await service.cancel("aptss:spark-weather");
|
||||||
|
await startPromise;
|
||||||
|
|
||||||
|
expect(cancelActiveTask).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.getState().tasks).toMatchObject([
|
||||||
|
{ taskKey: "aptss:spark-weather", status: "cancelled" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancelling a queued task does not abort the currently active task", async () => {
|
||||||
|
let releaseTask: (() => void) | undefined;
|
||||||
|
const cancelActiveTask = vi.fn(() => {
|
||||||
|
releaseTask?.();
|
||||||
|
});
|
||||||
|
const service = createUpdateCenterService({
|
||||||
|
loadItems: async () => [
|
||||||
|
createItem(),
|
||||||
|
{ ...createItem(), pkgname: "spark-clock" },
|
||||||
|
],
|
||||||
|
createTaskRunner: (queue: UpdateCenterQueue) => ({
|
||||||
|
cancelActiveTask,
|
||||||
|
runNextTask: async () => {
|
||||||
|
const task = queue.getNextQueuedTask();
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.markActiveTask(task.id, "installing");
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releaseTask = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
queue.getSnapshot().tasks.find((entry) => entry.id === task.id)
|
||||||
|
?.status !== "cancelled"
|
||||||
|
) {
|
||||||
|
queue.finishTask(task.id, "completed");
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.refresh();
|
||||||
|
|
||||||
|
const activeStart = service.start(["aptss:spark-weather"]);
|
||||||
|
await flushPromises();
|
||||||
|
const queuedStart = service.start(["aptss:spark-clock"]);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await service.cancel("aptss:spark-clock");
|
||||||
|
expect(cancelActiveTask).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
releaseTask?.();
|
||||||
|
await Promise.all([activeStart, queuedStart]);
|
||||||
|
|
||||||
|
expect(service.getState().tasks).toMatchObject([
|
||||||
|
{ taskKey: "aptss:spark-weather", status: "completed" },
|
||||||
|
{ taskKey: "aptss:spark-clock", status: "cancelled" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("superUserCmdProvider failure does not leave a task stuck in queued state", async () => {
|
||||||
|
const service = createUpdateCenterService({
|
||||||
|
loadItems: async () => [createItem()],
|
||||||
|
superUserCmdProvider: async () => {
|
||||||
|
throw new Error("pkexec unavailable");
|
||||||
|
},
|
||||||
|
createTaskRunner: () => ({
|
||||||
|
cancelActiveTask: vi.fn(),
|
||||||
|
runNextTask: async () => {
|
||||||
|
throw new Error(
|
||||||
|
"runner should not start when privilege lookup fails",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.refresh();
|
||||||
|
await service.start(["aptss:spark-weather"]);
|
||||||
|
|
||||||
|
expect(service.getState()).toMatchObject({
|
||||||
|
hasRunningTasks: false,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
status: "failed",
|
||||||
|
errorMessage: "pkexec unavailable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refresh exposes load-item failures as warnings", async () => {
|
||||||
|
const service = createUpdateCenterService({
|
||||||
|
loadItems: async () => {
|
||||||
|
throw new Error("apt list failed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await service.refresh();
|
||||||
|
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
items: [],
|
||||||
|
warnings: ["apt list failed"],
|
||||||
|
hasRunningTasks: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refresh failure clears previously loaded items so stale updates are not actionable", async () => {
|
||||||
|
let shouldFailRefresh = false;
|
||||||
|
const service = createUpdateCenterService({
|
||||||
|
loadItems: async () => {
|
||||||
|
if (shouldFailRefresh) {
|
||||||
|
throw new Error("apt list failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [createItem()];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await service.refresh()).toMatchObject({
|
||||||
|
items: [{ taskKey: "aptss:spark-weather" }],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
shouldFailRefresh = true;
|
||||||
|
|
||||||
|
expect(await service.refresh()).toMatchObject({
|
||||||
|
items: [],
|
||||||
|
warnings: ["apt list failed"],
|
||||||
|
hasRunningTasks: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refresh preserves warnings returned alongside successful items", async () => {
|
||||||
|
const service = createUpdateCenterService({
|
||||||
|
loadItems: async () => ({
|
||||||
|
items: [createItem()],
|
||||||
|
warnings: ["apm unavailable, showing aptss updates only"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await service.refresh();
|
||||||
|
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
packageName: "spark-weather",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: ["apm unavailable, showing aptss updates only"],
|
||||||
|
hasRunningTasks: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("window ipcRenderer typing matches the preload facade only", async () => {
|
||||||
|
type IpcFacade = Window["ipcRenderer"];
|
||||||
|
type HasOn = IpcFacade extends { on: (...args: never[]) => unknown }
|
||||||
|
? true
|
||||||
|
: false;
|
||||||
|
type HasInvoke = IpcFacade extends { invoke: (...args: never[]) => unknown }
|
||||||
|
? true
|
||||||
|
: false;
|
||||||
|
type HasPostMessage = IpcFacade extends {
|
||||||
|
postMessage: (...args: never[]) => unknown;
|
||||||
|
}
|
||||||
|
? true
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const typeShape: [HasOn, HasInvoke, HasPostMessage] = [true, true, false];
|
||||||
|
|
||||||
|
expect(typeShape).toEqual([true, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("default task runner forwards abort signals into download and install helpers", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
let downloadSignal: AbortSignal | undefined;
|
||||||
|
let installAborted = false;
|
||||||
|
let closeHandler: ((code: number | null) => void) | undefined;
|
||||||
|
|
||||||
|
vi.doMock(
|
||||||
|
"../../../../electron/main/backend/update-center/download",
|
||||||
|
() => ({
|
||||||
|
runAria2Download: vi.fn(async (context: { signal?: AbortSignal }) => {
|
||||||
|
downloadSignal = context.signal;
|
||||||
|
return { filePath: "/tmp/spark-weather.deb" };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.doMock("node:child_process", () => {
|
||||||
|
const spawn = vi.fn(() => {
|
||||||
|
const child = {
|
||||||
|
stdout: { on: vi.fn() },
|
||||||
|
stderr: { on: vi.fn() },
|
||||||
|
kill: vi.fn(() => {
|
||||||
|
installAborted = true;
|
||||||
|
closeHandler?.(1);
|
||||||
|
}),
|
||||||
|
on: vi.fn(
|
||||||
|
(event: string, callback: (code: number | null) => void) => {
|
||||||
|
if (event === "close") {
|
||||||
|
closeHandler = callback;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: { spawn },
|
||||||
|
spawn,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createTaskRunner } =
|
||||||
|
await import("../../../../electron/main/backend/update-center/install");
|
||||||
|
|
||||||
|
const queue = createUpdateCenterQueue();
|
||||||
|
const item = {
|
||||||
|
...createItem(),
|
||||||
|
downloadUrl: "https://example.invalid/spark-weather.deb",
|
||||||
|
fileName: "spark-weather.deb",
|
||||||
|
};
|
||||||
|
|
||||||
|
queue.setItems([item]);
|
||||||
|
queue.enqueueItem(item);
|
||||||
|
|
||||||
|
const runner = createTaskRunner(queue);
|
||||||
|
const runPromise = runner.runNextTask();
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
expect(downloadSignal).toBeInstanceOf(AbortSignal);
|
||||||
|
|
||||||
|
runner.cancelActiveTask();
|
||||||
|
|
||||||
|
const settled = await Promise.race([
|
||||||
|
runPromise.then(() => true),
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
setTimeout(() => resolve(false), 50);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(installAborted).toBe(true);
|
||||||
|
expect(settled).toBe(true);
|
||||||
|
|
||||||
|
vi.doUnmock("../../../../electron/main/backend/update-center/download");
|
||||||
|
vi.doUnmock("node:child_process");
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
});
|
||||||
232
src/__tests__/unit/update-center/store.test.ts
Normal file
232
src/__tests__/unit/update-center/store.test.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createUpdateCenterStore } from "@/modules/updateCenter";
|
||||||
|
|
||||||
|
const createSnapshot = (overrides = {}) => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
packageName: "spark-weather",
|
||||||
|
displayName: "Spark Weather",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
source: "aptss" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tasks: [],
|
||||||
|
warnings: [],
|
||||||
|
hasRunningTasks: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateCenter store", () => {
|
||||||
|
const open = vi.fn();
|
||||||
|
const refresh = vi.fn();
|
||||||
|
const start = vi.fn();
|
||||||
|
const onState = vi.fn();
|
||||||
|
const offState = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
open.mockReset();
|
||||||
|
refresh.mockReset();
|
||||||
|
start.mockReset();
|
||||||
|
onState.mockReset();
|
||||||
|
offState.mockReset();
|
||||||
|
|
||||||
|
Object.defineProperty(window, "updateCenter", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
open,
|
||||||
|
refresh,
|
||||||
|
ignore: vi.fn(),
|
||||||
|
unignore: vi.fn(),
|
||||||
|
start,
|
||||||
|
cancel: vi.fn(),
|
||||||
|
getState: vi.fn(),
|
||||||
|
onState,
|
||||||
|
offState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the modal with the initial snapshot", async () => {
|
||||||
|
const snapshot = createSnapshot();
|
||||||
|
open.mockResolvedValue(snapshot);
|
||||||
|
const store = createUpdateCenterStore();
|
||||||
|
|
||||||
|
await store.open();
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.isOpen.value).toBe(true);
|
||||||
|
expect(store.snapshot.value).toEqual(snapshot);
|
||||||
|
expect(store.filteredItems.value).toEqual(snapshot.items);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts only the selected non-ignored items", async () => {
|
||||||
|
const snapshot = createSnapshot({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
packageName: "spark-weather",
|
||||||
|
displayName: "Spark Weather",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
source: "aptss" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskKey: "apm:spark-clock",
|
||||||
|
packageName: "spark-clock",
|
||||||
|
displayName: "Spark Clock",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
source: "apm" as const,
|
||||||
|
ignored: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
open.mockResolvedValue(snapshot);
|
||||||
|
const store = createUpdateCenterStore();
|
||||||
|
|
||||||
|
await store.open();
|
||||||
|
store.toggleSelection("aptss:spark-weather");
|
||||||
|
store.toggleSelection("apm:spark-clock");
|
||||||
|
await store.startSelected();
|
||||||
|
|
||||||
|
expect(start).toHaveBeenCalledWith(["aptss:spark-weather"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks close requests while the snapshot reports running tasks", () => {
|
||||||
|
const store = createUpdateCenterStore();
|
||||||
|
store.isOpen.value = true;
|
||||||
|
store.snapshot.value = createSnapshot({ hasRunningTasks: true });
|
||||||
|
|
||||||
|
store.requestClose();
|
||||||
|
|
||||||
|
expect(store.isOpen.value).toBe(true);
|
||||||
|
expect(store.showCloseConfirm.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies pushed snapshots from the main process", () => {
|
||||||
|
let listener:
|
||||||
|
| ((snapshot: ReturnType<typeof createSnapshot>) => void)
|
||||||
|
| null = null;
|
||||||
|
onState.mockImplementation((nextListener) => {
|
||||||
|
listener = nextListener;
|
||||||
|
});
|
||||||
|
const store = createUpdateCenterStore();
|
||||||
|
|
||||||
|
store.bind();
|
||||||
|
|
||||||
|
const pushedSnapshot = createSnapshot({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
taskKey: "aptss:spark-music",
|
||||||
|
packageName: "spark-music",
|
||||||
|
displayName: "Spark Music",
|
||||||
|
currentVersion: "3.0.0",
|
||||||
|
newVersion: "3.1.0",
|
||||||
|
source: "aptss" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
listener?.(pushedSnapshot);
|
||||||
|
|
||||||
|
expect(onState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.snapshot.value).toEqual(pushedSnapshot);
|
||||||
|
expect(store.filteredItems.value).toEqual(pushedSnapshot.items);
|
||||||
|
|
||||||
|
store.unbind();
|
||||||
|
expect(offState).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes stale selected task keys when snapshots change", async () => {
|
||||||
|
open.mockResolvedValue(
|
||||||
|
createSnapshot({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
packageName: "spark-weather",
|
||||||
|
displayName: "Spark Weather",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
source: "aptss" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskKey: "apm:spark-clock",
|
||||||
|
packageName: "spark-clock",
|
||||||
|
displayName: "Spark Clock",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
source: "apm" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
refresh.mockResolvedValue(
|
||||||
|
createSnapshot({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
taskKey: "aptss:spark-music",
|
||||||
|
packageName: "spark-music",
|
||||||
|
displayName: "Spark Music",
|
||||||
|
currentVersion: "3.0.0",
|
||||||
|
newVersion: "3.1.0",
|
||||||
|
source: "aptss" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const store = createUpdateCenterStore();
|
||||||
|
|
||||||
|
await store.open();
|
||||||
|
store.toggleSelection("aptss:spark-weather");
|
||||||
|
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(true);
|
||||||
|
|
||||||
|
await store.refresh();
|
||||||
|
|
||||||
|
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(false);
|
||||||
|
|
||||||
|
await store.startSelected();
|
||||||
|
|
||||||
|
expect(start).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears selection across a close and reopen cycle", async () => {
|
||||||
|
const snapshot = createSnapshot({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
packageName: "spark-weather",
|
||||||
|
displayName: "Spark Weather",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
source: "aptss" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
open.mockResolvedValue(snapshot);
|
||||||
|
const store = createUpdateCenterStore();
|
||||||
|
|
||||||
|
await store.open();
|
||||||
|
store.toggleSelection("aptss:spark-weather");
|
||||||
|
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(true);
|
||||||
|
|
||||||
|
store.requestClose();
|
||||||
|
expect(store.isOpen.value).toBe(false);
|
||||||
|
|
||||||
|
await store.open();
|
||||||
|
|
||||||
|
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(false);
|
||||||
|
|
||||||
|
await store.startSelected();
|
||||||
|
|
||||||
|
expect(start).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
303
src/__tests__/unit/update-center/task-runner.test.ts
Normal file
303
src/__tests__/unit/update-center/task-runner.test.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
|
||||||
|
import {
|
||||||
|
createTaskRunner,
|
||||||
|
buildLegacySparkUpgradeCommand,
|
||||||
|
installUpdateItem,
|
||||||
|
} from "../../../../electron/main/backend/update-center/install";
|
||||||
|
import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
|
||||||
|
|
||||||
|
const childProcessMock = vi.hoisted(() => ({
|
||||||
|
spawnCalls: [] as Array<{ command: string; args: string[] }>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:child_process", () => ({
|
||||||
|
default: {
|
||||||
|
spawn: vi.fn((command: string, args: string[] = []) => {
|
||||||
|
childProcessMock.spawnCalls.push({ command, args });
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: { on: vi.fn() },
|
||||||
|
stderr: { on: vi.fn() },
|
||||||
|
on: vi.fn(
|
||||||
|
(
|
||||||
|
event: string,
|
||||||
|
callback: ((code?: number) => void) | (() => void),
|
||||||
|
) => {
|
||||||
|
if (event === "close") {
|
||||||
|
callback(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
spawn: vi.fn((command: string, args: string[] = []) => {
|
||||||
|
childProcessMock.spawnCalls.push({ command, args });
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: { on: vi.fn() },
|
||||||
|
stderr: { on: vi.fn() },
|
||||||
|
on: vi.fn(
|
||||||
|
(event: string, callback: ((code?: number) => void) | (() => void)) => {
|
||||||
|
if (event === "close") {
|
||||||
|
callback(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createAptssItem = (): UpdateCenterItem => ({
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
downloadUrl: "https://example.invalid/spark-weather_2.0.0_amd64.deb",
|
||||||
|
fileName: "spark-weather_2.0.0_amd64.deb",
|
||||||
|
});
|
||||||
|
|
||||||
|
const createApmItem = (): UpdateCenterItem => ({
|
||||||
|
pkgname: "spark-player",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
downloadUrl: "https://example.invalid/spark-player_2.0.0_amd64.deb",
|
||||||
|
fileName: "spark-player_2.0.0_amd64.deb",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update-center task runner", () => {
|
||||||
|
it("runs download then install and marks the task as completed", async () => {
|
||||||
|
const queue = createUpdateCenterQueue();
|
||||||
|
const item = createAptssItem();
|
||||||
|
const steps: string[] = [];
|
||||||
|
|
||||||
|
queue.setItems([item]);
|
||||||
|
const task = queue.enqueueItem(item);
|
||||||
|
|
||||||
|
const runner = createTaskRunner(queue, {
|
||||||
|
runDownload: async (context) => {
|
||||||
|
steps.push(`download:${context.task.pkgname}`);
|
||||||
|
context.onLog("download-started");
|
||||||
|
context.onProgress(40);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: `/tmp/${context.item.fileName}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
installItem: async (context) => {
|
||||||
|
steps.push(`install:${context.task.pkgname}`);
|
||||||
|
context.onLog("install-started");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runner.runNextTask();
|
||||||
|
|
||||||
|
expect(steps).toEqual(["download:spark-weather", "install:spark-weather"]);
|
||||||
|
expect(queue.getSnapshot()).toMatchObject({
|
||||||
|
hasRunningTasks: false,
|
||||||
|
warnings: [],
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: task.id,
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
status: "completed",
|
||||||
|
progress: 100,
|
||||||
|
logs: [
|
||||||
|
expect.objectContaining({ message: "download-started" }),
|
||||||
|
expect.objectContaining({ message: "install-started" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a direct aptss upgrade command instead of spark-update-tool", () => {
|
||||||
|
expect(
|
||||||
|
buildLegacySparkUpgradeCommand("spark-weather", "/usr/bin/pkexec"),
|
||||||
|
).toEqual({
|
||||||
|
execCommand: "/usr/bin/pkexec",
|
||||||
|
execParams: [
|
||||||
|
"/opt/spark-store/extras/shell-caller.sh",
|
||||||
|
"aptss",
|
||||||
|
"install",
|
||||||
|
"-y",
|
||||||
|
"spark-weather",
|
||||||
|
"--only-upgrade",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks close while a refresh or task is still running", () => {
|
||||||
|
const queue = createUpdateCenterQueue();
|
||||||
|
const item = createAptssItem();
|
||||||
|
|
||||||
|
expect(queue.getSnapshot().hasRunningTasks).toBe(false);
|
||||||
|
|
||||||
|
queue.startRefresh();
|
||||||
|
expect(queue.getSnapshot().hasRunningTasks).toBe(true);
|
||||||
|
|
||||||
|
queue.finishRefresh(["metadata warning"]);
|
||||||
|
expect(queue.getSnapshot()).toMatchObject({
|
||||||
|
hasRunningTasks: false,
|
||||||
|
warnings: ["metadata warning"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = queue.enqueueItem(item);
|
||||||
|
queue.markActiveTask(task.id, "downloading");
|
||||||
|
expect(queue.getSnapshot().hasRunningTasks).toBe(true);
|
||||||
|
|
||||||
|
queue.finishTask(task.id, "cancelled");
|
||||||
|
expect(queue.getSnapshot().hasRunningTasks).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates privilege escalation into the install path", async () => {
|
||||||
|
const queue = createUpdateCenterQueue();
|
||||||
|
const item = createAptssItem();
|
||||||
|
const installCalls: Array<{ superUserCmd?: string; filePath?: string }> =
|
||||||
|
[];
|
||||||
|
|
||||||
|
queue.setItems([item]);
|
||||||
|
queue.enqueueItem(item);
|
||||||
|
|
||||||
|
const runner = createTaskRunner(queue, {
|
||||||
|
superUserCmd: "/usr/bin/pkexec",
|
||||||
|
runDownload: async () => ({ filePath: "/tmp/spark-weather.deb" }),
|
||||||
|
installItem: async (context) => {
|
||||||
|
installCalls.push({
|
||||||
|
superUserCmd: context.superUserCmd,
|
||||||
|
filePath: context.filePath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runner.runNextTask();
|
||||||
|
|
||||||
|
expect(installCalls).toEqual([
|
||||||
|
{
|
||||||
|
superUserCmd: "/usr/bin/pkexec",
|
||||||
|
filePath: "/tmp/spark-weather.deb",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails fast for apm items without a file path", async () => {
|
||||||
|
const queue = createUpdateCenterQueue();
|
||||||
|
const item = {
|
||||||
|
...createApmItem(),
|
||||||
|
downloadUrl: undefined,
|
||||||
|
fileName: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
queue.setItems([item]);
|
||||||
|
const task = queue.enqueueItem(item);
|
||||||
|
|
||||||
|
const runner = createTaskRunner(queue, {
|
||||||
|
installItem: async (context) => {
|
||||||
|
throw new Error(`unexpected install for ${context.item.pkgname}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runner.runNextTask();
|
||||||
|
|
||||||
|
expect(queue.getSnapshot()).toMatchObject({
|
||||||
|
hasRunningTasks: false,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: task.id,
|
||||||
|
status: "failed",
|
||||||
|
error: "APM update task requires downloaded package metadata",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate work across concurrent runNextTask calls", async () => {
|
||||||
|
const queue = createUpdateCenterQueue();
|
||||||
|
const item = createAptssItem();
|
||||||
|
let releaseDownload: (() => void) | undefined;
|
||||||
|
const downloadGate = new Promise<void>((resolve) => {
|
||||||
|
releaseDownload = resolve;
|
||||||
|
});
|
||||||
|
const runDownload = vi.fn(async () => {
|
||||||
|
await downloadGate;
|
||||||
|
return { filePath: "/tmp/spark-weather.deb" };
|
||||||
|
});
|
||||||
|
const installItem = vi.fn(async () => {});
|
||||||
|
|
||||||
|
queue.setItems([item]);
|
||||||
|
queue.enqueueItem(item);
|
||||||
|
|
||||||
|
const runner = createTaskRunner(queue, {
|
||||||
|
runDownload,
|
||||||
|
installItem,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRun = runner.runNextTask();
|
||||||
|
const secondRun = runner.runNextTask();
|
||||||
|
releaseDownload?.();
|
||||||
|
|
||||||
|
const results = await Promise.all([firstRun, secondRun]);
|
||||||
|
|
||||||
|
expect(runDownload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(installItem).toHaveBeenCalledTimes(1);
|
||||||
|
expect(results[1]).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the task as failed when install fails", async () => {
|
||||||
|
const queue = createUpdateCenterQueue();
|
||||||
|
const item = createAptssItem();
|
||||||
|
|
||||||
|
queue.setItems([item]);
|
||||||
|
const task = queue.enqueueItem(item);
|
||||||
|
|
||||||
|
const runner = createTaskRunner(queue, {
|
||||||
|
runDownload: async (context) => {
|
||||||
|
context.onProgress(30);
|
||||||
|
return { filePath: "/tmp/spark-weather.deb" };
|
||||||
|
},
|
||||||
|
installItem: async () => {
|
||||||
|
throw new Error("install exploded");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runner.runNextTask();
|
||||||
|
|
||||||
|
expect(queue.getSnapshot()).toMatchObject({
|
||||||
|
hasRunningTasks: false,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: task.id,
|
||||||
|
status: "failed",
|
||||||
|
progress: 30,
|
||||||
|
error: "install exploded",
|
||||||
|
logs: [expect.objectContaining({ message: "install exploded" })],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall through to ssinstall for apm file installs", async () => {
|
||||||
|
childProcessMock.spawnCalls.length = 0;
|
||||||
|
|
||||||
|
await installUpdateItem({
|
||||||
|
item: createApmItem(),
|
||||||
|
filePath: "/tmp/spark-player.deb",
|
||||||
|
superUserCmd: "/usr/bin/pkexec",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(childProcessMock.spawnCalls).toEqual([
|
||||||
|
{
|
||||||
|
command: "/usr/bin/pkexec",
|
||||||
|
args: [
|
||||||
|
"/opt/spark-store/extras/shell-caller.sh",
|
||||||
|
"apm",
|
||||||
|
"ssinstall",
|
||||||
|
"/tmp/spark-player.deb",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -442,7 +442,10 @@
|
|||||||
import { computed, useAttrs, ref, watch } from "vue";
|
import { computed, useAttrs, ref, watch } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useInstallFeedback, downloads } from "../global/downloadStatus";
|
import { useInstallFeedback, downloads } from "../global/downloadStatus";
|
||||||
import { APM_STORE_BASE_URL, getHybridDefaultOrigin } from "../global/storeConfig";
|
import {
|
||||||
|
APM_STORE_BASE_URL,
|
||||||
|
getHybridDefaultOrigin,
|
||||||
|
} from "../global/storeConfig";
|
||||||
import type { App } from "../global/typedefinition";
|
import type { App } from "../global/typedefinition";
|
||||||
|
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
<!-- 应用数量较多时,使用虚拟滚动 -->
|
<!-- 应用数量较多时,使用虚拟滚动 -->
|
||||||
<RecycleScroller
|
<RecycleScroller
|
||||||
v-else-if="!loading"
|
v-else-if="!loading"
|
||||||
|
ref="scrollerRef"
|
||||||
class="scroller"
|
class="scroller"
|
||||||
:items="gridRows"
|
:items="gridRows"
|
||||||
:item-size="itemHeight"
|
:item-size="itemHeight"
|
||||||
@@ -77,16 +78,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onUnmounted } from "vue";
|
import { computed, ref, onMounted, onUnmounted, nextTick, watch } from "vue";
|
||||||
import { RecycleScroller } from "vue-virtual-scroller";
|
import { RecycleScroller } from "vue-virtual-scroller";
|
||||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||||
import AppCard from "./AppCard.vue";
|
import AppCard from "./AppCard.vue";
|
||||||
import type { App } from "../global/typedefinition";
|
import type { App } from "../global/typedefinition";
|
||||||
|
|
||||||
|
interface RecycleScrollerInstance {
|
||||||
|
$el: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
apps: App[];
|
apps: App[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
storeFilter?: "spark" | "apm" | "both";
|
storeFilter?: "spark" | "apm" | "both";
|
||||||
|
scrollKey?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
@@ -95,6 +101,7 @@ defineEmits<{
|
|||||||
|
|
||||||
// 当前列数
|
// 当前列数
|
||||||
const columns = ref(4);
|
const columns = ref(4);
|
||||||
|
const scrollerRef = ref<RecycleScrollerInstance | null>(null);
|
||||||
|
|
||||||
// 根据窗口宽度更新列数
|
// 根据窗口宽度更新列数
|
||||||
const updateColumns = () => {
|
const updateColumns = () => {
|
||||||
@@ -114,6 +121,19 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener("resize", updateColumns);
|
window.removeEventListener("resize", updateColumns);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.scrollKey,
|
||||||
|
async (nextKey, prevKey) => {
|
||||||
|
if (nextKey === prevKey || prevKey === undefined) return;
|
||||||
|
if (props.loading || props.apps.length <= 50) return;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
if (scrollerRef.value) {
|
||||||
|
scrollerRef.value.$el.scrollTop = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 网格列数类名
|
// 网格列数类名
|
||||||
const gridColumnsClass = computed(() => {
|
const gridColumnsClass = computed(() => {
|
||||||
const map: Record<number, string> = {
|
const map: Record<number, string> = {
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Transition
|
|
||||||
enter-active-class="duration-200 ease-out"
|
|
||||||
enter-from-class="opacity-0 scale-95"
|
|
||||||
enter-to-class="opacity-100 scale-100"
|
|
||||||
leave-active-class="duration-150 ease-in"
|
|
||||||
leave-from-class="opacity-100 scale-100"
|
|
||||||
leave-to-class="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="show"
|
|
||||||
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-full max-w-4xl max-h-[85vh] overflow-y-auto scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
|
|
||||||
软件更新
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
可更新的 APM 应用
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
|
|
||||||
:disabled="loading"
|
|
||||||
@click="$emit('refresh')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-sync-alt"></i>
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
|
|
||||||
:disabled="loading || apps.length === 0"
|
|
||||||
@click="$emit('toggle-all')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-check-square"></i>
|
|
||||||
全选/全不选
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
|
|
||||||
:disabled="loading || !hasSelected"
|
|
||||||
@click="$emit('upgrade-selected')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-upload"></i>
|
|
||||||
更新选中
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
|
|
||||||
@click="$emit('close')"
|
|
||||||
aria-label="关闭"
|
|
||||||
>
|
|
||||||
<i class="fas fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
|
|
||||||
>
|
|
||||||
正在检查可更新应用…
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="error"
|
|
||||||
class="rounded-2xl border border-rose-200/70 bg-rose-50/60 px-4 py-6 text-center text-sm text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="apps.length === 0"
|
|
||||||
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
|
|
||||||
>
|
|
||||||
暂无可更新应用
|
|
||||||
</div>
|
|
||||||
<div v-else class="space-y-3">
|
|
||||||
<label
|
|
||||||
v-for="app in apps"
|
|
||||||
:key="app.pkgname"
|
|
||||||
class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:gap-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
|
|
||||||
v-model="app.selected"
|
|
||||||
:disabled="app.upgrading"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold text-slate-900 dark:text-white">
|
|
||||||
{{ app.pkgname }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
当前 {{ app.currentVersion || "-" }} · 更新至
|
|
||||||
{{ app.newVersion || "-" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 sm:ml-auto">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
|
|
||||||
:disabled="app.upgrading"
|
|
||||||
@click.prevent="$emit('upgrade-one', app)"
|
|
||||||
>
|
|
||||||
<i class="fas fa-arrow-up"></i>
|
|
||||||
{{ app.upgrading ? "更新中…" : "更新" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { UpdateAppItem } from "../global/typedefinition";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
show: boolean;
|
|
||||||
apps: UpdateAppItem[];
|
|
||||||
loading: boolean;
|
|
||||||
error: string;
|
|
||||||
hasSelected: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "close"): void;
|
|
||||||
(e: "refresh"): void;
|
|
||||||
(e: "toggle-all"): void;
|
|
||||||
(e: "upgrade-selected"): void;
|
|
||||||
(e: "upgrade-one", app: UpdateAppItem): void;
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
93
src/components/UpdateCenterModal.vue
Normal file
93
src/components/UpdateCenterModal.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 scale-95"
|
||||||
|
enter-to-class="opacity-100 scale-100"
|
||||||
|
leave-active-class="duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 scale-100"
|
||||||
|
leave-to-class="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-6 lg:py-10"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<UpdateCenterToolbar
|
||||||
|
:search-query="store.searchQuery.value"
|
||||||
|
:selected-count="selectedCount"
|
||||||
|
@refresh="store.refresh"
|
||||||
|
@start-selected="emit('request-start-selected')"
|
||||||
|
@request-close="store.requestClose"
|
||||||
|
@update:search-query="emit('update:search-query', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="store.snapshot.value.warnings.length > 0"
|
||||||
|
class="mx-6 mt-4 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
v-for="warning in store.snapshot.value.warnings"
|
||||||
|
:key="warning"
|
||||||
|
class="leading-6"
|
||||||
|
>
|
||||||
|
{{ warning }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
|
||||||
|
>
|
||||||
|
<UpdateCenterList
|
||||||
|
:items="store.filteredItems.value"
|
||||||
|
:tasks="store.snapshot.value.tasks"
|
||||||
|
:selected-task-keys="store.selectedTaskKeys.value"
|
||||||
|
@toggle-selection="emit('toggle-selection', $event)"
|
||||||
|
/>
|
||||||
|
<UpdateCenterLogPanel :tasks="store.snapshot.value.tasks" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UpdateCenterMigrationConfirm
|
||||||
|
:show="store.showMigrationConfirm.value"
|
||||||
|
@close="emit('dismiss-migration-confirm')"
|
||||||
|
@confirm="emit('confirm-migration-start')"
|
||||||
|
/>
|
||||||
|
<UpdateCenterCloseConfirm
|
||||||
|
:show="store.showCloseConfirm.value"
|
||||||
|
@close="emit('dismiss-close-confirm')"
|
||||||
|
@confirm="emit('confirm-close')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import type { UpdateCenterStore } from "@/modules/updateCenter";
|
||||||
|
|
||||||
|
import UpdateCenterCloseConfirm from "./update-center/UpdateCenterCloseConfirm.vue";
|
||||||
|
import UpdateCenterList from "./update-center/UpdateCenterList.vue";
|
||||||
|
import UpdateCenterLogPanel from "./update-center/UpdateCenterLogPanel.vue";
|
||||||
|
import UpdateCenterMigrationConfirm from "./update-center/UpdateCenterMigrationConfirm.vue";
|
||||||
|
import UpdateCenterToolbar from "./update-center/UpdateCenterToolbar.vue";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:search-query", value: string): void;
|
||||||
|
(e: "toggle-selection", taskKey: string): void;
|
||||||
|
(e: "request-start-selected"): void;
|
||||||
|
(e: "confirm-migration-start"): void;
|
||||||
|
(e: "dismiss-migration-confirm"): void;
|
||||||
|
(e: "confirm-close"): void;
|
||||||
|
(e: "dismiss-close-confirm"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
store: UpdateCenterStore;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const selectedCount = computed(() => props.store.getSelectedItems().length);
|
||||||
|
</script>
|
||||||
44
src/components/update-center/UpdateCenterCloseConfirm.vue
Normal file
44
src/components/update-center/UpdateCenterCloseConfirm.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-slate-950/45 px-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full max-w-md rounded-3xl border border-amber-200 bg-white p-6 shadow-2xl dark:border-amber-500/30 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<p class="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
关闭确认
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
更新仍在进行中,确定关闭此窗口吗?
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 dark:border-slate-700 dark:text-slate-200"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
继续查看
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl bg-amber-500 px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
@click="$emit('confirm')"
|
||||||
|
>
|
||||||
|
确认关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: "close"): void;
|
||||||
|
(e: "confirm"): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
116
src/components/update-center/UpdateCenterItem.vue
Normal file
116
src/components/update-center/UpdateCenterItem.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<label
|
||||||
|
class="flex flex-col gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
|
||||||
|
:checked="selected"
|
||||||
|
:disabled="item.ignored === true"
|
||||||
|
@change="$emit('toggle-selection')"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<p class="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{{ item.displayName }}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
{{ sourceLabel }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.isMigration"
|
||||||
|
class="rounded-full bg-brand/10 px-2.5 py-1 text-xs font-semibold text-brand"
|
||||||
|
>
|
||||||
|
将迁移到 APM
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.ignored === true"
|
||||||
|
class="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
已忽略
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{{ item.packageName }} · 当前 {{ item.currentVersion }} · 更新至
|
||||||
|
{{ item.newVersion }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="item.ignored === true"
|
||||||
|
class="mt-2 text-xs font-medium text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
已忽略的更新不会加入本次任务。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="task"
|
||||||
|
class="text-right text-sm font-semibold text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<p>{{ statusLabel }}</p>
|
||||||
|
<p v-if="showProgress" class="mt-1">{{ progressText }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showProgress" class="space-y-2">
|
||||||
|
<div
|
||||||
|
class="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-gradient-to-r from-brand to-brand-dark"
|
||||||
|
:style="progressStyle"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
UpdateCenterItem,
|
||||||
|
UpdateCenterTaskState,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: UpdateCenterItem;
|
||||||
|
task?: UpdateCenterTaskState;
|
||||||
|
selected: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: "toggle-selection"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const sourceLabel = computed(() => {
|
||||||
|
return props.item.source === "apm" ? "APM" : "传统deb";
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
switch (props.task?.status) {
|
||||||
|
case "downloading":
|
||||||
|
return "下载中";
|
||||||
|
case "installing":
|
||||||
|
return "安装中";
|
||||||
|
case "completed":
|
||||||
|
return "已完成";
|
||||||
|
case "failed":
|
||||||
|
return "失败";
|
||||||
|
case "cancelled":
|
||||||
|
return "已取消";
|
||||||
|
default:
|
||||||
|
return "待处理";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const showProgress = computed(() => {
|
||||||
|
return (
|
||||||
|
props.task?.status === "downloading" || props.task?.status === "installing"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressText = computed(() => `${props.task?.progress ?? 0}%`);
|
||||||
|
const progressStyle = computed(() => ({ width: progressText.value }));
|
||||||
|
</script>
|
||||||
47
src/components/update-center/UpdateCenterList.vue
Normal file
47
src/components/update-center/UpdateCenterList.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="min-h-0 overflow-y-auto border-r border-slate-200/70 p-6 dark:border-slate-800/70"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="items.length === 0"
|
||||||
|
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
暂无可展示的更新任务
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<UpdateCenterItem
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.taskKey"
|
||||||
|
:item="item"
|
||||||
|
:task="taskMap.get(item.taskKey)"
|
||||||
|
:selected="selectedTaskKeys.has(item.taskKey)"
|
||||||
|
@toggle-selection="$emit('toggle-selection', item.taskKey)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
UpdateCenterItem as UpdateCenterItemModel,
|
||||||
|
UpdateCenterTaskState,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
import UpdateCenterItem from "./UpdateCenterItem.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: UpdateCenterItemModel[];
|
||||||
|
tasks: UpdateCenterTaskState[];
|
||||||
|
selectedTaskKeys: Set<string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: "toggle-selection", taskKey: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const taskMap = computed(() => {
|
||||||
|
return new Map(props.tasks.map((task) => [task.taskKey, task]));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
45
src/components/update-center/UpdateCenterLogPanel.vue
Normal file
45
src/components/update-center/UpdateCenterLogPanel.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="hidden min-h-0 flex-col border-t border-slate-200/70 bg-slate-50/70 p-6 lg:flex lg:border-t-0 dark:border-slate-800/70 dark:bg-slate-950/50"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
|
任务日志
|
||||||
|
</p>
|
||||||
|
<span class="text-xs text-slate-500 dark:text-slate-400"
|
||||||
|
>{{ tasks.length }} 项</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 min-h-0 flex-1 space-y-4 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-if="tasks.length === 0"
|
||||||
|
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
暂无运行日志
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.taskKey"
|
||||||
|
class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 dark:border-slate-800/70 dark:bg-slate-900/70"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
|
{{ task.packageName }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{{ task.status }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-3 text-xs leading-5 text-slate-600 dark:text-slate-300">
|
||||||
|
{{ task.logs.at(-1)?.message || task.errorMessage || "等待日志输出" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UpdateCenterTaskState } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
tasks: UpdateCenterTaskState[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-slate-950/45 px-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full max-w-md rounded-3xl border border-slate-200/70 bg-white p-6 shadow-2xl dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<p class="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
迁移确认
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
部分传统 deb 更新将迁移到 APM 管理。
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 dark:border-slate-700 dark:text-slate-200"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
@click="$emit('confirm')"
|
||||||
|
>
|
||||||
|
继续更新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: "close"): void;
|
||||||
|
(e: "confirm"): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
73
src/components/update-center/UpdateCenterToolbar.vue
Normal file
73
src/components/update-center/UpdateCenterToolbar.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-4 border-b border-slate-200/70 px-6 py-5 dark:border-slate-800/70"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
软件更新
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
集中管理 APM 与传统 deb 更新任务
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||||
|
@click="$emit('refresh')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
|
||||||
|
:disabled="selectedCount === 0"
|
||||||
|
@click="$emit('start-selected')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
更新选中 ({{ selectedCount }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭"
|
||||||
|
class="inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:text-slate-300"
|
||||||
|
@click="$emit('request-close')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">搜索更新</span>
|
||||||
|
<input
|
||||||
|
:value="searchQuery"
|
||||||
|
type="search"
|
||||||
|
placeholder="搜索应用或包名"
|
||||||
|
class="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-brand/60 focus:bg-white dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:focus:bg-slate-900"
|
||||||
|
@input="handleInput"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
searchQuery: string;
|
||||||
|
selectedCount: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "refresh"): void;
|
||||||
|
(e: "start-selected"): void;
|
||||||
|
(e: "request-close"): void;
|
||||||
|
(e: "update:search-query", value: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleInput = (event: Event): void => {
|
||||||
|
const target = event.target as HTMLInputElement | null;
|
||||||
|
emit("update:search-query", target?.value ?? props.searchQuery);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -102,7 +102,10 @@ export async function loadPriorityConfig(arch: string): Promise<void> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
isPriorityConfigLoaded = true;
|
isPriorityConfigLoaded = true;
|
||||||
console.log("[PriorityConfig] 已从服务器加载优先级配置:", dynamicPriorityConfig);
|
console.log(
|
||||||
|
"[PriorityConfig] 已从服务器加载优先级配置:",
|
||||||
|
dynamicPriorityConfig,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// 配置文件不存在,默认优先 Spark
|
// 配置文件不存在,默认优先 Spark
|
||||||
console.log("[PriorityConfig] 服务器无配置文件,使用默认 Spark 优先");
|
console.log("[PriorityConfig] 服务器无配置文件,使用默认 Spark 优先");
|
||||||
@@ -136,21 +139,6 @@ function resetPriorityConfig(): void {
|
|||||||
isPriorityConfigLoaded = true;
|
isPriorityConfigLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查配置是否为空(没有任何规则)
|
|
||||||
*/
|
|
||||||
function isConfigEmpty(): boolean {
|
|
||||||
const { sparkPriority, apmPriority } = dynamicPriorityConfig;
|
|
||||||
return (
|
|
||||||
sparkPriority.pkgnames.length === 0 &&
|
|
||||||
sparkPriority.categories.length === 0 &&
|
|
||||||
sparkPriority.tags.length === 0 &&
|
|
||||||
apmPriority.pkgnames.length === 0 &&
|
|
||||||
apmPriority.categories.length === 0 &&
|
|
||||||
apmPriority.tags.length === 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取混合模式下应用的默认优先来源
|
* 获取混合模式下应用的默认优先来源
|
||||||
* 判断优先级(从高到低):
|
* 判断优先级(从高到低):
|
||||||
|
|||||||
@@ -123,6 +123,69 @@ export interface UpdateAppItem {
|
|||||||
upgrading?: boolean;
|
upgrading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateSource = "aptss" | "apm";
|
||||||
|
|
||||||
|
export type UpdateCenterTaskStatus =
|
||||||
|
| "queued"
|
||||||
|
| "downloading"
|
||||||
|
| "installing"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
|
export interface UpdateCenterItem {
|
||||||
|
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 UpdateCenterTaskState {
|
||||||
|
taskKey: string;
|
||||||
|
packageName: string;
|
||||||
|
source: UpdateSource;
|
||||||
|
status: UpdateCenterTaskStatus;
|
||||||
|
progress: number;
|
||||||
|
logs: Array<{ time: number; message: string }>;
|
||||||
|
errorMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCenterSnapshot {
|
||||||
|
items: UpdateCenterItem[];
|
||||||
|
tasks: UpdateCenterTaskState[];
|
||||||
|
warnings: string[];
|
||||||
|
hasRunningTasks: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCenterBridge {
|
||||||
|
open: () => Promise<UpdateCenterSnapshot>;
|
||||||
|
refresh: () => Promise<UpdateCenterSnapshot>;
|
||||||
|
ignore: (payload: {
|
||||||
|
packageName: string;
|
||||||
|
newVersion: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
unignore: (payload: {
|
||||||
|
packageName: string;
|
||||||
|
newVersion: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
start: (taskKeys: string[]) => Promise<void>;
|
||||||
|
cancel: (taskKey: string) => Promise<void>;
|
||||||
|
getState: () => Promise<UpdateCenterSnapshot>;
|
||||||
|
onState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void;
|
||||||
|
offState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**************Below are type from main process ********************/
|
/**************Below are type from main process ********************/
|
||||||
export interface InstalledAppInfo {
|
export interface InstalledAppInfo {
|
||||||
pkgname: string;
|
pkgname: string;
|
||||||
|
|||||||
182
src/modules/updateCenter.ts
Normal file
182
src/modules/updateCenter.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { computed, ref, type ComputedRef, type Ref } from "vue";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
UpdateCenterItem,
|
||||||
|
UpdateCenterSnapshot,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const EMPTY_SNAPSHOT: UpdateCenterSnapshot = {
|
||||||
|
items: [],
|
||||||
|
tasks: [],
|
||||||
|
warnings: [],
|
||||||
|
hasRunningTasks: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UpdateCenterStore {
|
||||||
|
isOpen: Ref<boolean>;
|
||||||
|
showCloseConfirm: Ref<boolean>;
|
||||||
|
showMigrationConfirm: Ref<boolean>;
|
||||||
|
searchQuery: Ref<string>;
|
||||||
|
selectedTaskKeys: Ref<Set<string>>;
|
||||||
|
snapshot: Ref<UpdateCenterSnapshot>;
|
||||||
|
filteredItems: ComputedRef<UpdateCenterItem[]>;
|
||||||
|
bind: () => void;
|
||||||
|
unbind: () => void;
|
||||||
|
open: () => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
toggleSelection: (taskKey: string) => void;
|
||||||
|
getSelectedItems: () => UpdateCenterItem[];
|
||||||
|
closeNow: () => void;
|
||||||
|
startSelected: () => Promise<void>;
|
||||||
|
requestClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesSearch = (item: UpdateCenterItem, query: string): boolean => {
|
||||||
|
if (query.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = query.toLowerCase();
|
||||||
|
return [item.displayName, item.packageName, item.taskKey].some((value) =>
|
||||||
|
value.toLowerCase().includes(normalizedQuery),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUpdateCenterStore = (): UpdateCenterStore => {
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const showCloseConfirm = ref(false);
|
||||||
|
const showMigrationConfirm = ref(false);
|
||||||
|
const searchQuery = ref("");
|
||||||
|
const selectedTaskKeys = ref(new Set<string>());
|
||||||
|
const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT);
|
||||||
|
|
||||||
|
const resetSessionState = (): void => {
|
||||||
|
showCloseConfirm.value = false;
|
||||||
|
showMigrationConfirm.value = false;
|
||||||
|
searchQuery.value = "";
|
||||||
|
selectedTaskKeys.value = new Set();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySnapshot = (nextSnapshot: UpdateCenterSnapshot): void => {
|
||||||
|
const selectableTaskKeys = new Set(
|
||||||
|
nextSnapshot.items
|
||||||
|
.filter((item) => item.ignored !== true)
|
||||||
|
.map((item) => item.taskKey),
|
||||||
|
);
|
||||||
|
selectedTaskKeys.value = new Set(
|
||||||
|
[...selectedTaskKeys.value].filter((taskKey) =>
|
||||||
|
selectableTaskKeys.has(taskKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
snapshot.value = nextSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
const query = searchQuery.value.trim();
|
||||||
|
return snapshot.value.items.filter((item) => matchesSearch(item, query));
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleState = (nextSnapshot: UpdateCenterSnapshot): void => {
|
||||||
|
applySnapshot(nextSnapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
let isBound = false;
|
||||||
|
|
||||||
|
const bind = (): void => {
|
||||||
|
if (isBound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.updateCenter.onState(handleState);
|
||||||
|
isBound = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unbind = (): void => {
|
||||||
|
if (!isBound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.updateCenter.offState(handleState);
|
||||||
|
isBound = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = async (): Promise<void> => {
|
||||||
|
resetSessionState();
|
||||||
|
const nextSnapshot = await window.updateCenter.open();
|
||||||
|
applySnapshot(nextSnapshot);
|
||||||
|
isOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
|
const nextSnapshot = await window.updateCenter.refresh();
|
||||||
|
applySnapshot(nextSnapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelection = (taskKey: string): void => {
|
||||||
|
const item = snapshot.value.items.find(
|
||||||
|
(entry) => entry.taskKey === taskKey,
|
||||||
|
);
|
||||||
|
if (!item || item.ignored === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSelection = new Set(selectedTaskKeys.value);
|
||||||
|
if (nextSelection.has(taskKey)) {
|
||||||
|
nextSelection.delete(taskKey);
|
||||||
|
} else {
|
||||||
|
nextSelection.add(taskKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTaskKeys.value = nextSelection;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedItems = (): UpdateCenterItem[] => {
|
||||||
|
return snapshot.value.items.filter(
|
||||||
|
(item) =>
|
||||||
|
selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeNow = (): void => {
|
||||||
|
resetSessionState();
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startSelected = async (): Promise<void> => {
|
||||||
|
const taskKeys = getSelectedItems().map((item) => item.taskKey);
|
||||||
|
|
||||||
|
if (taskKeys.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await window.updateCenter.start(taskKeys);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestClose = (): void => {
|
||||||
|
if (snapshot.value.hasRunningTasks) {
|
||||||
|
showCloseConfirm.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
showCloseConfirm,
|
||||||
|
showMigrationConfirm,
|
||||||
|
searchQuery,
|
||||||
|
selectedTaskKeys,
|
||||||
|
snapshot,
|
||||||
|
filteredItems,
|
||||||
|
bind,
|
||||||
|
unbind,
|
||||||
|
open,
|
||||||
|
refresh,
|
||||||
|
toggleSelection,
|
||||||
|
getSelectedItems,
|
||||||
|
closeNow,
|
||||||
|
startSelected,
|
||||||
|
requestClose,
|
||||||
|
};
|
||||||
|
};
|
||||||
37
src/vite-env.d.ts
vendored
37
src/vite-env.d.ts
vendored
@@ -1,18 +1,30 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import type { UpdateCenterBridge } from "@/global/typedefinition";
|
||||||
|
|
||||||
declare module "*.vue" {
|
declare module "*.vue" {
|
||||||
import type { DefineComponent } from "vue";
|
import type { DefineComponent } from "vue";
|
||||||
const component: DefineComponent<{}, {}, any>;
|
const component: DefineComponent<{}, {}, any>;
|
||||||
export default component;
|
export default component;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
// expose in the `electron/preload/index.ts`
|
// expose in the `electron/preload/index.ts`
|
||||||
ipcRenderer: import("electron").IpcRenderer;
|
ipcRenderer: IpcRendererFacade;
|
||||||
apm_store: {
|
apm_store: {
|
||||||
arch: string;
|
arch: string;
|
||||||
};
|
};
|
||||||
|
updateCenter: UpdateCenterBridge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IpcRendererFacade {
|
||||||
|
on: import("electron").IpcRenderer["on"];
|
||||||
|
off: import("electron").IpcRenderer["off"];
|
||||||
|
send: import("electron").IpcRenderer["send"];
|
||||||
|
invoke: import("electron").IpcRenderer["invoke"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPC channel type definitions
|
// IPC channel type definitions
|
||||||
@@ -22,25 +34,4 @@ declare interface IpcChannels {
|
|||||||
|
|
||||||
declare const __APP_VERSION__: string;
|
declare const __APP_VERSION__: string;
|
||||||
|
|
||||||
// vue-virtual-scroller type declarations
|
export {};
|
||||||
declare module "vue-virtual-scroller" {
|
|
||||||
import { DefineComponent } from "vue";
|
|
||||||
|
|
||||||
export const RecycleScroller: DefineComponent<{
|
|
||||||
items: any[];
|
|
||||||
itemSize: number;
|
|
||||||
keyField?: string;
|
|
||||||
direction?: "vertical" | "horizontal";
|
|
||||||
buffer?: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const DynamicScroller: DefineComponent<{
|
|
||||||
items: any[];
|
|
||||||
minItemSize: number;
|
|
||||||
keyField?: string;
|
|
||||||
direction?: "vertical" | "horizontal";
|
|
||||||
buffer?: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const DynamicScrollerItem: DefineComponent<{}>;
|
|
||||||
}
|
|
||||||
|
|||||||
21
src/vue-virtual-scroller.d.ts
vendored
Normal file
21
src/vue-virtual-scroller.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
declare module "vue-virtual-scroller" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
|
||||||
|
export const RecycleScroller: DefineComponent<{
|
||||||
|
items: unknown[];
|
||||||
|
itemSize: number;
|
||||||
|
keyField?: string;
|
||||||
|
direction?: "vertical" | "horizontal";
|
||||||
|
buffer?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const DynamicScroller: DefineComponent<{
|
||||||
|
items: unknown[];
|
||||||
|
minItemSize: number;
|
||||||
|
keyField?: string;
|
||||||
|
direction?: "vertical" | "horizontal";
|
||||||
|
buffer?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const DynamicScrollerItem: DefineComponent<Record<string, never>>;
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
],
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/__tests__"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.node.json"
|
"path": "./tsconfig.node.json"
|
||||||
|
|||||||
Reference in New Issue
Block a user