Files
spark-store/electron/main/backend/install-manager.ts

920 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BrowserWindow, dialog, ipcMain, WebContents } from "electron";
import { spawn, ChildProcess, exec } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs";
import path from "node:path";
import pino from "pino";
import { ChannelPayload } from "../../typedefinition";
import axios from "axios";
const logger = pino({ name: "install-manager" });
type InstallTask = {
id: number;
pkgname: string;
execCommand: string;
execParams: string[];
download_process: ChildProcess | null;
install_process: ChildProcess | null;
webContents: WebContents | null;
downloadDir?: string;
metalinkUrl?: string;
filename?: string;
origin: "spark" | "apm";
};
const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh";
export const tasks = new Map<number, InstallTask>();
let idle = true; // Indicates if the installation manager is idle
const checkSuperUserCommand = async (): Promise<string> => {
let superUserCmd = "";
const execAsync = promisify(exec);
if (process.getuid && process.getuid() !== 0) {
const { stdout, stderr } = await execAsync("which /usr/bin/pkexec");
if (stderr) {
logger.error("没有找到 pkexec 命令");
return;
}
logger.info(`找到提升权限命令: ${stdout.trim()}`);
superUserCmd = stdout.trim();
if (superUserCmd.length === 0) {
logger.error("没有找到提升权限的命令 pkexec!");
}
}
return superUserCmd;
};
const runCommandCapture = async (execCommand: string, execParams: string[]) => {
return await new Promise<{ code: number; stdout: string; stderr: string }>(
(resolve) => {
const child = spawn(execCommand, execParams, {
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", (err) => {
resolve({ code: -1, stdout, stderr: err.message });
});
child.on("close", (code) => {
resolve({ code: typeof code === "number" ? code : -1, stdout, stderr });
});
},
);
};
/** 检测本机是否已安装 apm 命令 */
const checkApmAvailable = async (): Promise<boolean> => {
const { code, stdout } = await runCommandCapture("which", ["apm"]);
const found = code === 0 && stdout.trim().length > 0;
if (!found) logger.info("未检测到 apm 命令");
return found;
};
/** 提权执行 shell-caller aptss install apm 安装 APM安装后需用户重启电脑 */
const runInstallApm = async (superUserCmd: string): Promise<boolean> => {
const execCommand = superUserCmd || SHELL_CALLER_PATH;
const execParams = superUserCmd
? [SHELL_CALLER_PATH, "aptss", "install", "apm"]
: [SHELL_CALLER_PATH, "aptss", "install", "apm"];
logger.info(`执行安装 APM: ${execCommand} ${execParams.join(" ")}`);
const { code, stdout, stderr } = await runCommandCapture(
execCommand,
execParams,
);
if (code !== 0) {
logger.error({ code, stdout, stderr }, "安装 APM 失败");
return false;
}
logger.info("安装 APM 完成");
return true;
};
const parseUpgradableList = (output: string) => {
const apps: Array<{
pkgname: string;
newVersion: string;
currentVersion: string;
raw: string;
}> = [];
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith("Listing")) continue;
if (trimmed.startsWith("[INFO]")) continue;
if (trimmed.includes("=") && !trimmed.includes("/")) continue;
if (!trimmed.includes("/")) continue;
const tokens = trimmed.split(/\s+/);
if (tokens.length < 2) continue;
const pkgToken = tokens[0];
const pkgname = pkgToken.split("/")[0];
const newVersion = tokens[1] || "";
const currentMatch = trimmed.match(
/\[(?:upgradable from|from):\s*([^\]\s]+)\]/i,
);
const currentToken = tokens[5] || "";
const currentVersion =
currentMatch?.[1] || currentToken.replace("[", "").replace("]", "");
if (!pkgname) continue;
apps.push({ pkgname, newVersion, currentVersion, raw: trimmed });
}
return apps;
};
// Listen for download requests from renderer process
ipcMain.on("queue-install", async (event, download_json) => {
const download =
typeof download_json === "string"
? JSON.parse(download_json)
: download_json;
const { id, pkgname, metalinkUrl, filename, upgradeOnly, origin } =
download || {};
if (!id || !pkgname) {
logger.warn("passed arguments missing id or pkgname");
return;
}
logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`);
// 避免重复添加同一任务,但允许重试下载
if (tasks.has(id) && !download.retry) {
tasks.get(id)?.webContents?.send("install-log", {
id,
time: Date.now(),
message: `任务id ${id} 已在列表中,忽略重复添加`,
});
tasks.get(id)?.webContents?.send("install-complete", {
id: id,
success: false,
time: Date.now(),
exitCode: -1,
message: `{"message":"任务id ${id} 已在列表中,忽略重复添加","stdout":"","stderr":""}`,
});
return;
}
const webContents = event.sender;
const superUserCmd = await checkSuperUserCommand();
let execCommand = "";
const execParams = [];
const downloadDir = `/tmp/spark-store/download/${pkgname}`;
// APM 应用:若本机没有 apm 命令,弹窗提示并可选提权安装 APM安装后需重启电脑
if (origin === "apm") {
const hasApm = await checkApmAvailable();
if (!hasApm) {
const win = BrowserWindow.fromWebContents(webContents);
const { response } = await dialog.showMessageBox(win ?? undefined, {
type: "question",
title: "需要安装 APM",
message: "此应用需要使用 APM 安装。",
detail:
"APM是星火应用商店的容器包管理器安装APM后方可安装此应用是否确认安装",
buttons: ["确认", "取消"],
defaultId: 0,
cancelId: 1,
});
if (response !== 0) {
webContents.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: "用户取消安装 APM无法继续安装此应用",
stdout: "",
stderr: "",
}),
});
return;
}
const installApmOk = await runInstallApm(superUserCmd);
if (!installApmOk) {
webContents.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: "安装 APM 失败,请检查网络或权限后重试",
stdout: "",
stderr: "",
}),
});
return;
} else {
// 安装APM成功提示用户已安装成功需要重启后方可展示应用
await dialog.showMessageBox(win ?? undefined, {
type: "info",
title: "APM 安装成功",
message: "恭喜您APM 已成功安装",
detail:
"APM 应用需重启后方可展示和使用,若完成安装后无法在应用列表中展示,请重启电脑后继续。",
buttons: ["确定"],
defaultId: 0,
});
}
}
}
if (origin === "spark") {
// Spark Store logic
if (upgradeOnly) {
execCommand = "pkexec";
execParams.push("spark-update-tool", pkgname);
} else {
execCommand = superUserCmd || SHELL_CALLER_PATH;
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
if (metalinkUrl && filename) {
execParams.push(
"ssinstall",
`${downloadDir}/${filename}`,
"--delete-after-install",
);
} else {
execParams.push("aptss", "install", "-y", pkgname);
}
}
} else {
// APM Store logic
execCommand = superUserCmd || SHELL_CALLER_PATH;
if (superUserCmd) {
execParams.push(SHELL_CALLER_PATH);
}
execParams.push("apm");
if (metalinkUrl && filename) {
execParams.push("ssaudit", `${downloadDir}/${filename}`);
} else {
execParams.push("install", "-y", pkgname);
}
}
const task: InstallTask = {
id,
pkgname,
execCommand,
execParams,
download_process: null,
install_process: null,
webContents,
downloadDir,
metalinkUrl,
filename,
origin: origin || "apm",
};
tasks.set(id, task);
if (idle) processNextInQueue();
});
// Cancel Handler
ipcMain.on("cancel-install", (event, id) => {
if (tasks.has(id)) {
const task = tasks.get(id);
if (task) {
task.download_process?.kill(); // Kill the download process
task.install_process?.kill(); // Kill the install process
logger.info(`已取消任务: ${id}`);
}
// Note: 'close' handler usually handles cleanup
}
});
async function processNextInQueue() {
if (!idle) return;
// Always take the first task to ensure sequence
const task = Array.from(tasks.values())[0];
if (!task) {
idle = true;
return;
}
idle = false;
const { webContents, id, downloadDir } = task;
const sendLog = (msg: string) => {
webContents?.send("install-log", { id, time: Date.now(), message: msg });
};
const sendStatus = (status: string) => {
webContents?.send("install-status", {
id,
time: Date.now(),
message: status,
});
};
try {
// 1. Metalink & Aria2c Phase
if (task.metalinkUrl) {
try {
if (!fs.existsSync(downloadDir)) {
fs.mkdirSync(downloadDir, { recursive: true });
}
} catch (err) {
logger.error(`无法创建目录 ${downloadDir}: ${err}`);
throw err;
}
const metalinkPath = path.join(downloadDir, `${task.filename}.metalink`);
sendLog(`正在获取 Metalink 文件: ${task.metalinkUrl}`);
const response = await axios.get(task.metalinkUrl, {
baseURL: "https://erotica.spark-app.store",
responseType: "stream",
});
const writer = fs.createWriteStream(metalinkPath);
response.data.pipe(writer);
await new Promise<void>((resolve, reject) => {
writer.on("finish", resolve);
writer.on("error", reject);
});
sendLog("Metalink 文件下载完成");
// 清理下载目录中的旧文件(保留 .metalink 文件),防止 aria2c 因同名文件卡住
const existingFiles = fs.readdirSync(downloadDir);
for (const file of existingFiles) {
if (file.endsWith(".metalink")) continue;
const filePath = path.join(downloadDir, file);
try {
fs.unlinkSync(filePath);
sendLog(`已清理旧文件: ${file}`);
} catch (err) {
logger.warn(`清理文件失败 ${filePath}: ${err}`);
}
}
// Aria2c
const aria2Args = [
`--dir=${downloadDir}`,
"--allow-overwrite=true",
"--summary-interval=1",
"--connect-timeout=10",
"--timeout=15",
"--max-tries=3",
"--retry-wait=5",
"--max-concurrent-downloads=4",
"--min-split-size=1M",
"--lowest-speed-limit=1K",
"--auto-file-renaming=false",
"-M",
metalinkPath,
];
sendStatus("downloading");
// 下载重试逻辑卡在0% 30秒则重启最多3次
const maxRetries = 3;
let retryCount = 0;
let downloadSuccess = false;
while (retryCount < maxRetries && !downloadSuccess) {
if (retryCount > 0) {
sendLog(`${retryCount} 次重试下载...`);
webContents?.send("install-progress", { id, progress: 0 });
// 重试前也清理旧文件
const retryFiles = fs.readdirSync(downloadDir);
for (const file of retryFiles) {
if (file.endsWith(".metalink")) continue;
const filePath = path.join(downloadDir, file);
try {
fs.unlinkSync(filePath);
} catch (cleanErr) {
logger.warn(`重试清理文件失败 ${filePath}: ${cleanErr}`);
}
}
}
try {
await new Promise<void>((resolve, reject) => {
sendLog(`启动下载: aria2c ${aria2Args.join(" ")}`);
const child = spawn("aria2c", aria2Args);
task.download_process = child;
let lastProgressTime = Date.now();
let lastProgress = 0;
const zeroProgressTimeout = 30000; // 0%卡死30秒超时
const progressCheckInterval = 3000; // 每3秒检查一次
// 设置超时检测定时器
const timeoutChecker = setInterval(() => {
const now = Date.now();
// 只在进度为0时检查超时
if (
lastProgress === 0 &&
now - lastProgressTime > zeroProgressTimeout
) {
clearInterval(timeoutChecker);
child.kill();
reject(
new Error(`下载卡在0%超过 ${zeroProgressTimeout / 1000}`),
);
}
}, progressCheckInterval);
child.stdout.on("data", (data) => {
const str = data.toString();
// Match ( 12%) or (12%)
const match = str.match(/[0-9]+(\.[0-9]+)?%/g);
if (match) {
const p = parseFloat(match.at(-1)) / 100;
if (p > lastProgress) {
lastProgress = p;
lastProgressTime = Date.now();
}
webContents?.send("install-progress", { id, progress: p });
}
});
child.stderr.on("data", (d) => sendLog(`aria2c: ${d}`));
child.on("close", (code) => {
clearInterval(timeoutChecker);
if (code === 0) {
webContents?.send("install-progress", { id, progress: 1 });
resolve();
} else {
reject(new Error(`Aria2c exited with code ${code}`));
}
});
child.on("error", (err) => {
clearInterval(timeoutChecker);
reject(err);
});
});
downloadSuccess = true;
} catch (err) {
retryCount++;
if (retryCount >= maxRetries) {
throw new Error(`下载失败,已重试 ${maxRetries} 次: ${err}`);
}
sendLog(`下载失败,准备重试 (${retryCount}/${maxRetries})`);
// 等待2秒后重试
await new Promise((r) => setTimeout(r, 2000));
}
}
}
// 2. Install Phase
sendStatus("installing");
const cmdString = `${task.execCommand} ${task.execParams.join(" ")}`;
sendLog(`执行安装: ${cmdString}`);
logger.info(`启动安装: ${cmdString}`);
const result = await new Promise<{
code: number;
stdout: string;
stderr: string;
}>((resolve, reject) => {
const child = spawn(task.execCommand, task.execParams, {
shell: false,
env: process.env,
});
task.install_process = child;
let stdout = "";
let stderr = "";
child.stdout.on("data", (d) => {
const s = d.toString();
stdout += s;
sendLog(s);
});
child.stderr.on("data", (d) => {
const s = d.toString();
stderr += s;
sendLog(s);
});
child.on("close", (code) => {
resolve({ code: code ?? -1, stdout, stderr });
});
child.on("error", (err) => {
reject(err);
});
});
// Completion
const success = result.code === 0;
const msgObj = {
message: success ? "安装完成" : `安装失败,退出码 ${result.code}`,
stdout: result.stdout,
stderr: result.stderr,
};
if (success) logger.info(msgObj);
else logger.error(msgObj);
webContents?.send("install-complete", {
id,
success,
time: Date.now(),
exitCode: result.code,
message: JSON.stringify(msgObj),
});
} catch (error) {
logger.error(`Task ${id} failed: ${error}`);
webContents?.send("install-complete", {
id,
success: false,
time: Date.now(),
exitCode: -1,
message: JSON.stringify({
message: error instanceof Error ? error.message : String(error),
stdout: "",
stderr: "",
}),
});
} finally {
tasks.delete(id);
idle = true;
// Trigger next
if (tasks.size > 0) {
processNextInQueue();
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ipcMain.handle("check-installed", async (_event, payload: any) => {
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
const origin = typeof payload === "string" ? "spark" : payload.origin;
if (!pkgname) {
logger.warn("check-installed missing pkgname");
return false;
}
logger.info(`检查应用是否已安装: ${pkgname} (来源: ${origin})`);
let isInstalled = false;
if (origin === "apm") {
const { code, stdout } = await runCommandCapture("apm", [
"list",
"--installed",
]);
if (code === 0) {
const cleanStdout = stdout.replace(
// eslint-disable-next-line no-control-regex
/\x1b\[[0-9;]*m/g,
"",
);
const lines = cleanStdout.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (
!trimmed ||
trimmed.startsWith("Listing") ||
trimmed.startsWith("[INFO]") ||
trimmed.startsWith("警告")
)
continue;
if (trimmed.includes("/")) {
const installedPkg = trimmed.split("/")[0].trim();
if (installedPkg === pkgname) {
isInstalled = true;
logger.info(`应用已安装 (APM检测): ${pkgname}`);
break;
}
}
}
}
return isInstalled;
}
const checkScript = "/opt/spark-store/extras/check-is-installed";
// 首先尝试使用内置脚本
if (fs.existsSync(checkScript)) {
const child = spawn(checkScript, [pkgname], {
shell: false,
env: process.env,
});
await new Promise<void>((resolve) => {
child.on("error", (err) => {
logger.error(`check-installed 脚本执行失败: ${err?.message || err}`);
resolve();
});
child.on("close", (code) => {
if (code === 0) {
isInstalled = true;
logger.info(`应用已安装 (脚本检测): ${pkgname}`);
}
resolve();
});
});
if (isInstalled) return true;
}
return isInstalled;
});
ipcMain.on("remove-installed", async (_event, payload) => {
const webContents = _event.sender;
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
const origin = typeof payload === "string" ? "spark" : payload.origin;
if (!pkgname) {
logger.warn("remove-installed missing pkgname");
return;
}
logger.info(`卸载已安装应用: ${pkgname} (来源: ${origin})`);
let execCommand = "";
const execParams = [];
const superUserCmd = await checkSuperUserCommand();
execCommand = superUserCmd || SHELL_CALLER_PATH;
if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
if (origin === "spark") {
execParams.push("aptss", "remove", pkgname);
} else {
execParams.push("apm", "autoremove", "-y", pkgname);
}
const child = spawn(execCommand, execParams, {
shell: false,
env: process.env,
});
let output = "";
child.stdout.on("data", (data) => {
const chunk = data.toString();
output += chunk;
webContents.send("remove-progress", chunk);
});
child.on("close", (code) => {
const success = code === 0;
// 拼接json消息
const messageJSONObj = {
message: success ? "卸载完成" : `卸载失败,退出码 ${code}`,
stdout: output,
stderr: "",
};
if (success) {
logger.info(messageJSONObj);
} else {
logger.error(messageJSONObj);
}
webContents.send("remove-complete", {
id: 0,
success: success,
time: Date.now(),
exitCode: code,
message: JSON.stringify(messageJSONObj),
origin: origin,
} satisfies ChannelPayload);
});
});
ipcMain.handle("list-installed", async () => {
const apmBasePath = "/var/lib/apm/apm/files/ace-env/var/lib/apm";
try {
if (!fs.existsSync(apmBasePath)) {
logger.warn(`APM base path not found: ${apmBasePath}`);
return {
success: false,
message: "APM base path not found",
apps: [],
};
}
const packages = fs.readdirSync(apmBasePath, { withFileTypes: true });
const installedApps: Array<{
pkgname: string;
name: string;
version: string;
arch: string;
flags: string;
origin: "spark" | "apm";
icon?: string;
isDependency: boolean;
}> = [];
for (const pkg of packages) {
if (!pkg.isDirectory()) continue;
const pkgname = pkg.name;
const pkgPath = path.join(apmBasePath, pkgname);
const { code, stdout } = await runCommandCapture("apm", [
"list",
pkgname,
]);
if (code !== 0) {
logger.warn(`Failed to list package ${pkgname}: ${stdout}`);
continue;
}
const cleanStdout = stdout.replace(
// eslint-disable-next-line no-control-regex
/\x1b\[[0-9;]*m/g,
"",
);
const lines = cleanStdout.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (
!trimmed ||
trimmed.startsWith("Listing") ||
trimmed.startsWith("[INFO]") ||
trimmed.startsWith("警告")
)
continue;
const match = trimmed.match(
/^(\S+)\/\S+,\S+\s+(\S+)\s+(\S+)\s+\[(.+)\]$/,
);
if (!match) continue;
const [, listedPkgname, version, arch, flags] = match;
if (listedPkgname !== pkgname) continue;
let appName = pkgname;
let icon = "";
const entriesPath = path.join(pkgPath, "entries", "applications");
const hasEntries = fs.existsSync(entriesPath);
if (hasEntries) {
const desktopFiles = fs.readdirSync(entriesPath);
for (const file of desktopFiles) {
if (file.endsWith(".desktop")) {
const desktopPath = path.join(entriesPath, file);
const content = fs.readFileSync(desktopPath, "utf-8");
const nameMatch = content.match(/^Name=(.+)$/m);
const iconMatch = content.match(/^Icon=(.+)$/m);
if (nameMatch) appName = nameMatch[1].trim();
if (iconMatch) icon = iconMatch[1].trim();
break;
}
}
}
installedApps.push({
pkgname,
name: appName,
version,
arch,
flags,
origin: "apm",
icon: icon || undefined,
isDependency: !hasEntries,
});
}
}
installedApps.sort((a, b) => {
const getOrder = (app: { pkgname: string; isDependency: boolean }) => {
if (app.isDependency) return 2;
if (app.pkgname.startsWith("amber-pm")) return 1;
return 0;
};
const aOrder = getOrder(a);
const bOrder = getOrder(b);
if (aOrder !== bOrder) return aOrder - bOrder;
return a.pkgname.localeCompare(b.pkgname);
});
return { success: true, apps: installedApps };
} catch (error) {
logger.error(
`list-installed failed: ${error instanceof Error ? error.message : String(error)}`,
);
return {
success: false,
message: error instanceof Error ? error.message : String(error),
apps: [],
};
}
});
ipcMain.handle("list-upgradable", async () => {
const { code, stdout, stderr } = await runCommandCapture("apm", [
"list",
"--upgradable",
]);
if (code !== 0) {
logger.error(`list-upgradable failed: ${stderr || stdout}`);
return {
success: false,
message: stderr || stdout || `list-upgradable failed with code ${code}`,
apps: [],
};
}
const apps = parseUpgradableList(stdout);
return { success: true, apps };
});
ipcMain.handle("check-apm-available", async () => {
return await checkApmAvailable();
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ipcMain.handle("uninstall-installed", async (_event, payload: any) => {
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
const origin = typeof payload === "string" ? "spark" : payload.origin;
if (!pkgname) {
logger.warn("uninstall-installed missing pkgname");
return { success: false, message: "missing pkgname" };
}
const superUserCmd = await checkSuperUserCommand();
const execCommand = superUserCmd || SHELL_CALLER_PATH;
const execParams = superUserCmd ? [SHELL_CALLER_PATH] : [];
if (origin === "apm") {
execParams.push("apm", "remove", "-y", pkgname);
} else {
execParams.push("aptss", "remove", "-y", pkgname);
}
const { code, stdout, stderr } = await runCommandCapture(
execCommand,
execParams,
);
const success = code === 0;
if (success) {
logger.info(`卸载完成: ${pkgname}`);
} else {
logger.error(`卸载失败: ${pkgname} ${stderr || stdout}`);
}
return {
success,
message: success
? "卸载完成"
: stderr || stdout || `卸载失败,退出码 ${code}`,
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ipcMain.handle("launch-app", async (_event, payload: any) => {
const pkgname = typeof payload === "string" ? payload : payload.pkgname;
const origin = typeof payload === "string" ? "spark" : payload.origin;
if (!pkgname) {
logger.warn("No pkgname provided for launch-app");
}
let execCommand = "/opt/spark-store/extras/app-launcher";
let execParams = ["start", pkgname];
if (origin === "apm") {
execCommand = "apm";
execParams = ["launch", pkgname];
}
logger.info(
`Launching app: ${pkgname} with command: ${execCommand} ${execParams.join(" ")}`,
);
spawn(execCommand, execParams, {
shell: false,
env: process.env,
detached: true,
stdio: "ignore",
}).unref();
});