From e2f59b3cdf32c765db73b8328b3217efeda6e8d5 Mon Sep 17 00:00:00 2001 From: shenmo Date: Sun, 12 Apr 2026 17:53:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=8D=E7=94=A8=E4=B8=8B=E8=BD=BD=E4=B8=AD?= =?UTF-8?q?=E5=BF=83(1/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/backend/shared-installer.ts | 368 ++++++++++++++++++ .../main/backend/update-center/download.ts | 78 +--- electron/main/backend/update-center/index.ts | 16 +- .../main/backend/update-center/install.ts | 118 +----- electron/main/backend/update-center/query.ts | 4 +- electron/main/backend/update-center/queue.ts | 55 ++- .../main/backend/update-center/service.ts | 140 +++---- src/components/UpdateCenterModal.vue | 14 +- src/modules/updateCenter.ts | 45 ++- 9 files changed, 532 insertions(+), 306 deletions(-) create mode 100644 electron/main/backend/shared-installer.ts diff --git a/electron/main/backend/shared-installer.ts b/electron/main/backend/shared-installer.ts new file mode 100644 index 00000000..d1f31c2a --- /dev/null +++ b/electron/main/backend/shared-installer.ts @@ -0,0 +1,368 @@ +/** + * 共享的安装/下载逻辑 + * 被 install-manager.ts 和 update-center 共同使用 + */ +import { spawn, ChildProcess } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import axios from "axios"; +import pino from "pino"; + +const logger = pino({ name: "shared-installer" }); + +export const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh"; + +export interface DownloadOptions { + pkgname: string; + metalinkUrl: string; + filename: string; + downloadDir: string; + onLog?: (msg: string) => void; + onProgress?: (progress: number) => void; + onStatus?: (status: string) => void; + signal?: AbortSignal; +} + +export interface DownloadResult { + filePath: string; + downloadDir: string; +} + +/** + * 下载 metalink 文件并使用 aria2c 下载 deb 包 + * 与 install-manager.ts 中的下载逻辑保持一致 + */ +export const downloadPackage = async ({ + pkgname, + metalinkUrl, + filename, + downloadDir, + onLog, + onProgress, + onStatus, + signal, +}: DownloadOptions): Promise => { + // 1. 创建下载目录 + try { + if (!fs.existsSync(downloadDir)) { + fs.mkdirSync(downloadDir, { recursive: true }); + } + } catch (err) { + logger.error(`无法创建目录 ${downloadDir}: ${err}`); + throw err; + } + + const metalinkPath = path.join(downloadDir, `${filename}.metalink`); + + onLog?.(`正在获取 Metalink 文件: ${metalinkUrl}`); + + // 2. 下载 metalink 文件 + const response = await axios.get(metalinkUrl, { + baseURL: "https://erotica.spark-app.store", + responseType: "stream", + }); + + const writer = createWriteStream(metalinkPath); + response.data.pipe(writer); + + await new Promise((resolve, reject) => { + writer.on("finish", resolve); + writer.on("error", reject); + }); + + onLog?.("Metalink 文件下载完成"); + + // 3. 清理下载目录中的旧文件(保留 .metalink 文件) + 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); + onLog?.(`已清理旧文件: ${file}`); + } catch (err) { + logger.warn(`清理文件失败 ${filePath}: ${err}`); + } + } + + // 4. 使用 aria2c 下载 deb 文件 + 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, + ]; + + onStatus?.("downloading"); + + // 下载重试逻辑:每次超时时间递增,最多3次 + const timeoutList = [3000, 5000, 15000]; + let retryCount = 0; + let downloadSuccess = false; + + while (retryCount < timeoutList.length && !downloadSuccess) { + const currentTimeout = timeoutList[retryCount]; + + if (retryCount > 0) { + onLog?.(`第 ${retryCount} 次重试下载...`); + onProgress?.(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((resolve, reject) => { + onLog?.(`启动下载: aria2c ${aria2Args.join(" ")}`); + const child = spawn("aria2c", aria2Args); + + let lastProgressTime = Date.now(); + let lastProgress = 0; + const progressCheckInterval = 1000; + + // 设置超时检测定时器 + const timeoutChecker = setInterval(() => { + const now = Date.now(); + // 只在进度为0时检查超时 + if (lastProgress === 0 && now - lastProgressTime > currentTimeout) { + clearInterval(timeoutChecker); + child.kill(); + reject(new Error(`下载卡在0%超过 ${currentTimeout / 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(); + } + onProgress?.(p); + } + }); + child.stderr.on("data", (d) => onLog?.(`aria2c: ${d}`)); + + // 处理取消信号 + const abortHandler = () => { + clearInterval(timeoutChecker); + child.kill(); + reject(new Error("下载已取消")); + }; + + signal?.addEventListener("abort", abortHandler, { once: true }); + + child.on("close", (code) => { + clearInterval(timeoutChecker); + signal?.removeEventListener("abort", abortHandler); + + if (code === 0) { + onProgress?.(1); + resolve(); + } else { + reject(new Error(`Aria2c exited with code ${code}`)); + } + }); + child.on("error", (err) => { + clearInterval(timeoutChecker); + signal?.removeEventListener("abort", abortHandler); + reject(err); + }); + }); + + // 检查是否已取消 + if (signal?.aborted) { + throw new Error("下载已取消"); + } + downloadSuccess = true; + } catch (err) { + retryCount++; + if (retryCount >= timeoutList.length) { + throw new Error(`下载失败,已重试 ${timeoutList.length} 次: ${err}`); + } + onLog?.(`下载失败,准备重试 (${retryCount}/${timeoutList.length})`); + // 等待2秒后重试 + await new Promise((r) => setTimeout(r, 2000)); + } + } + + const filePath = path.join(downloadDir, filename); + return { filePath, downloadDir }; +}; + +export interface InstallOptions { + pkgname: string; + filePath: string; + origin: "spark" | "apm"; + superUserCmd?: string; + onLog?: (msg: string) => void; + signal?: AbortSignal; +} + +/** + * 安装已下载的包 + * 与 install-manager.ts 中的安装逻辑保持一致 + */ +export const installPackage = async ({ + pkgname, + filePath, + origin, + superUserCmd, + onLog, + signal, +}: InstallOptions): Promise => { + // 构建安装命令 + let execCommand = ""; + const execParams: string[] = []; + + if (origin === "spark") { + execCommand = superUserCmd || SHELL_CALLER_PATH; + if (superUserCmd) execParams.push(SHELL_CALLER_PATH); + execParams.push( + "ssinstall", + filePath, + "--delete-after-install", + "--no-create-desktop-entry", + "--native" + ); + } else { + // APM + execCommand = superUserCmd || SHELL_CALLER_PATH; + if (superUserCmd) { + execParams.push(SHELL_CALLER_PATH); + } + execParams.push("apm", "ssinstall", filePath); + } + + const cmdString = `${execCommand} ${execParams.join(" ")}`; + onLog?.(`执行安装: ${cmdString}`); + logger.info(`启动安装: ${cmdString}`); + + return new Promise((resolve, reject) => { + const child = spawn(execCommand, execParams, { + shell: false, + env: process.env, + }); + + let stdout = ""; + let stderr = ""; + let logBuffer = ""; + let logBufferTimer: NodeJS.Timeout | null = null; + const LOG_FLUSH_MS = 100; + + const flushLogBuffer = () => { + if (logBuffer.length > 0) { + onLog?.(logBuffer); + logBuffer = ""; + } + logBufferTimer = null; + }; + + const bufferedSendLog = (message: string) => { + logBuffer += message; + if (!logBufferTimer) { + logBufferTimer = setTimeout(flushLogBuffer, LOG_FLUSH_MS); + } + }; + + // 处理取消信号 + const abortHandler = () => { + child.kill(); + reject(new Error("安装已取消")); + }; + + signal?.addEventListener("abort", abortHandler, { once: true }); + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + bufferedSendLog(data.toString()); + }); + + child.stderr?.on("data", (data) => { + stderr += data.toString(); + bufferedSendLog(data.toString()); + }); + + child.on("error", (err) => { + signal?.removeEventListener("abort", abortHandler); + if (logBufferTimer) clearTimeout(logBufferTimer); + flushLogBuffer(); + reject(err); + }); + + child.on("close", (code) => { + signal?.removeEventListener("abort", abortHandler); + if (logBufferTimer) clearTimeout(logBufferTimer); + flushLogBuffer(); + + if (code === 0) { + resolve(); + } else { + reject(new Error(`安装失败,退出码: ${code}`)); + } + }); + }); +}; + +/** + * 检查是否有 apm 命令 + */ +export const checkApmAvailable = async (): Promise => { + return new Promise((resolve) => { + const child = spawn("which", ["apm"]); + let stdout = ""; + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + child.on("close", (code) => { + resolve(code === 0 && stdout.trim().length > 0); + }); + child.on("error", () => { + resolve(false); + }); + }); +}; + +/** + * 检查提权命令 + */ +export const checkSuperUserCommand = async (): Promise => { + return new Promise((resolve) => { + const child = spawn("which", ["/usr/bin/pkexec"]); + let stdout = ""; + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + child.on("close", (code) => { + if (code === 0) { + resolve(stdout.trim()); + } else { + resolve(""); + } + }); + child.on("error", () => { + resolve(""); + }); + }); +}; diff --git a/electron/main/backend/update-center/download.ts b/electron/main/backend/update-center/download.ts index f6e030c4..b9be8326 100644 --- a/electron/main/backend/update-center/download.ts +++ b/electron/main/backend/update-center/download.ts @@ -1,7 +1,5 @@ -import { mkdir } from "node:fs/promises"; import { join } from "node:path"; -import { spawn } from "node:child_process"; - +import { downloadPackage, type DownloadResult } from "../shared-installer"; import type { UpdateCenterItem } from "./types"; export interface Aria2DownloadResult { @@ -16,8 +14,6 @@ export interface RunAria2DownloadOptions { signal?: AbortSignal; } -const PROGRESS_PATTERN = /(\d{1,3}(?:\.\d+)?)%/; - export const runAria2Download = async ({ item, downloadDir, @@ -29,68 +25,18 @@ export const runAria2Download = async ({ throw new Error(`Missing download metadata for ${item.pkgname}`); } - await mkdir(downloadDir, { recursive: true }); - - const filePath = join(downloadDir, item.fileName); - - // Use .metalink URL for download (same as Qt version) + // 使用与商店安装相同的下载逻辑 const metalinkUrl = `${item.downloadUrl}.metalink`; - - await new Promise((resolve, reject) => { - const child = spawn("aria2c", [ - "--dir", - downloadDir, - "--out", - item.fileName, - "--enable-rpc=false", - "--console-log-level=warn", - "--summary-interval=1", - "--allow-overwrite=true", - "--connect-timeout=30", - "--max-tries=3", - metalinkUrl, - ]); - - const abortDownload = () => { - child.kill(); - reject(new Error(`Update task cancelled: ${item.pkgname}`)); - }; - - if (signal?.aborted) { - abortDownload(); - return; - } - - signal?.addEventListener("abort", abortDownload, { once: true }); - - const handleOutput = (chunk: Buffer) => { - const message = chunk.toString().trim(); - if (!message) { - return; - } - - onLog?.(message); - const progressMatch = message.match(PROGRESS_PATTERN); - if (progressMatch) { - onProgress?.(Number(progressMatch[1])); - } - }; - - child.stdout?.on("data", handleOutput); - child.stderr?.on("data", handleOutput); - child.on("error", reject); - child.on("close", (code) => { - signal?.removeEventListener("abort", abortDownload); - if (code === 0) { - resolve(); - return; - } - - reject(new Error(`aria2c exited with code ${code ?? -1}`)); - }); + + const result = await downloadPackage({ + pkgname: item.pkgname, + metalinkUrl, + filename: item.fileName, + downloadDir, + onLog, + onProgress, + signal, }); - onProgress?.(100); - - return { filePath }; + return { filePath: result.filePath }; }; diff --git a/electron/main/backend/update-center/index.ts b/electron/main/backend/update-center/index.ts index 76c100e6..c8280c96 100644 --- a/electron/main/backend/update-center/index.ts +++ b/electron/main/backend/update-center/index.ts @@ -155,20 +155,30 @@ const loadAptssItemMetadata = async ( | { item: UpdateCenterItem; warning?: undefined } | { item: null; warning: string } > => { + console.log(`[DEBUG] Loading APTSS metadata for ${item.pkgname}`); const printUrisCommand = getAptssPrintUrisCommand(item.pkgname); + console.log(`[DEBUG] APTSS command: ${printUrisCommand.command} ${printUrisCommand.args.join(' ')}`); + const metadataResult = await runCommand( printUrisCommand.command, printUrisCommand.args, ); + console.log(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`); + console.log(`[DEBUG] APTSS metadata stdout: ${metadataResult.stdout.substring(0, 500)}`); + console.log(`[DEBUG] APTSS metadata stderr: ${metadataResult.stderr.substring(0, 500)}`); + const commandError = getCommandError( `aptss metadata query for ${item.pkgname}`, metadataResult, ); if (commandError) { + console.log(`[DEBUG] APTSS metadata error: ${commandError}`); return { item: null, warning: commandError }; } const metadata = parsePrintUrisOutput(metadataResult.stdout); + console.log(`[DEBUG] APTSS parsed metadata:`, metadata); + if (!metadata) { return { item: null, @@ -424,14 +434,8 @@ export const initializeUpdateCenter = (): UpdateCenterService => { return updateCenterService; } - const superUserCmdProvider = async (): Promise => { - const installManager = await import("../install-manager.js"); - return installManager.checkSuperUserCommand(); - }; - updateCenterService = createUpdateCenterService({ loadItems: loadUpdateCenterItems, - superUserCmdProvider, }); registerUpdateCenterIpc(ipcMain, updateCenterService); diff --git a/electron/main/backend/update-center/install.ts b/electron/main/backend/update-center/install.ts index 937e5620..5660b541 100644 --- a/electron/main/backend/update-center/install.ts +++ b/electron/main/backend/update-center/install.ts @@ -1,19 +1,12 @@ -import { spawn } from "node:child_process"; import { join } from "node:path"; import { runAria2Download, type Aria2DownloadResult } from "./download"; +import { installPackage } from "../shared-installer"; import type { UpdateCenterQueue, UpdateCenterTask } from "./queue"; import type { UpdateCenterItem } from "./types"; -const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh"; -const SSINSTALL_PATH = "/usr/bin/ssinstall"; const DEFAULT_DOWNLOAD_ROOT = "/tmp/spark-store/update-center"; -export interface UpdateCommand { - execCommand: string; - execParams: string[]; -} - export interface InstallUpdateItemOptions { item: UpdateCenterItem; filePath?: string; @@ -55,73 +48,10 @@ export interface CreateTaskRunnerOptions extends TaskRunnerDependencies { superUserCmd?: string; } -const runCommand = async ( - execCommand: string, - execParams: string[], - onLog?: (message: string) => void, - signal?: AbortSignal, -): Promise => { - await new Promise((resolve, reject) => { - const child = spawn(execCommand, execParams, { - shell: false, - env: process.env, - }); - - const handleOutput = (chunk: Buffer) => { - const message = chunk.toString().trim(); - if (message) { - onLog?.(message); - } - }; - - const abortCommand = () => { - child.kill(); - reject(new Error(`Update task cancelled: ${execParams.join(" ")}`)); - }; - - if (signal?.aborted) { - abortCommand(); - return; - } - - signal?.addEventListener("abort", abortCommand, { once: true }); - - child.stdout?.on("data", handleOutput); - child.stderr?.on("data", handleOutput); - child.on("error", reject); - child.on("close", (code) => { - signal?.removeEventListener("abort", abortCommand); - if (code === 0) { - resolve(); - return; - } - - reject(new Error(`${execCommand} exited with code ${code ?? -1}`)); - }); - }); -}; - -const buildPrivilegedCommand = ( - command: string, - args: string[], - superUserCmd?: string, -): UpdateCommand => { - if (superUserCmd) { - return { - execCommand: superUserCmd, - execParams: [command, ...args], - }; - } - - return { - execCommand: command, - execParams: args, - }; -}; - -// Removed buildLegacySparkUpgradeCommand - all updates now require downloading the deb package first -// to avoid aptss install popup. Use ssinstall with downloaded deb file instead. - +/** + * 安装更新项 + * 使用与商店安装相同的逻辑 + */ export const installUpdateItem = async ({ item, filePath, @@ -135,33 +65,17 @@ export const installUpdateItem = async ({ ); } - if (item.source === "apm") { - const installCommand = buildPrivilegedCommand( - SHELL_CALLER_PATH, - ["apm", "ssinstall", filePath], - superUserCmd, - ); - await runCommand( - installCommand.execCommand, - installCommand.execParams, - onLog, - signal, - ); - return; - } - - // APTSS (Spark Store) packages use ssinstall - const installCommand = buildPrivilegedCommand( - SSINSTALL_PATH, - [filePath, "--delete-after-install", "--no-create-desktop-entry", "--native"], + // 使用与商店安装相同的安装逻辑 + const origin = item.source === "apm" ? "apm" : "spark"; + + await installPackage({ + pkgname: item.pkgname, + filePath, + origin, superUserCmd, - ); - await runCommand( - installCommand.execCommand, - installCommand.execParams, onLog, signal, - ); + }); }; export const createTaskRunner = ( @@ -194,11 +108,7 @@ export const createTaskRunner = ( return { cancelActiveTask: () => { - if (!activeAbortController || activeAbortController.signal.aborted) { - return; - } - - activeAbortController.abort(); + activeAbortController?.abort(); }, runNextTask: async () => { if (inFlightTask) { diff --git a/electron/main/backend/update-center/query.ts b/electron/main/backend/update-center/query.ts index 74ce1e4c..1cb49422 100644 --- a/electron/main/backend/update-center/query.ts +++ b/electron/main/backend/update-center/query.ts @@ -270,7 +270,9 @@ export const parsePrintUrisOutput = ( return null; } - const [, downloadUrl, fileName, size, sha512] = match; + const [, rawDownloadUrl, fileName, size, sha512] = match; + // Clean up the URL: remove backticks and extra spaces + const downloadUrl = rawDownloadUrl.replace(/[`'"]/g, "").trim(); return { downloadUrl, fileName, diff --git a/electron/main/backend/update-center/queue.ts b/electron/main/backend/update-center/queue.ts index cc2b21e1..7d492f2e 100644 --- a/electron/main/backend/update-center/queue.ts +++ b/electron/main/backend/update-center/queue.ts @@ -88,8 +88,8 @@ export const createUpdateCenterQueue = (): UpdateCenterQueue => { let refreshing = false; let nextTaskId = 1; - const getTask = (taskId: number): UpdateCenterTask | undefined => - tasks.find((task) => task.id === taskId); + const getTaskIndex = (taskId: number): number => + tasks.findIndex((task) => task.id === taskId); return { setItems: (nextItems) => { @@ -117,40 +117,59 @@ export const createUpdateCenterQueue = (): UpdateCenterQueue => { return task; }, markActiveTask: (taskId, status) => { - const task = getTask(taskId); - if (!task) { + const taskIndex = getTaskIndex(taskId); + if (taskIndex === -1) { return; } - task.status = status; + // 创建新的 task 对象和新的 tasks 数组以触发状态更新 + tasks = tasks.map((task, index) => + index === taskIndex ? { ...task, status } : task, + ); }, updateTaskProgress: (taskId, progress) => { - const task = getTask(taskId); - if (!task) { + const taskIndex = getTaskIndex(taskId); + if (taskIndex === -1) { return; } - task.progress = clampProgress(progress); + // 创建新的 task 对象和新的 tasks 数组以触发状态更新 + tasks = tasks.map((task, index) => + index === taskIndex + ? { ...task, progress: clampProgress(progress) } + : task, + ); }, appendTaskLog: (taskId, message, time = Date.now()) => { - const task = getTask(taskId); - if (!task) { + const taskIndex = getTaskIndex(taskId); + if (taskIndex === -1) { return; } - task.logs = [...task.logs, { time, message }]; + // 创建新的 task 对象和新的 tasks 数组以触发状态更新 + tasks = tasks.map((task, index) => + index === taskIndex + ? { ...task, logs: [...task.logs, { time, message }] } + : task, + ); }, finishTask: (taskId, status, error) => { - const task = getTask(taskId); - if (!task) { + const taskIndex = getTaskIndex(taskId); + if (taskIndex === -1) { return; } - task.status = status; - task.error = error; - if (status === "completed") { - task.progress = 100; - } + // 创建新的 task 对象和新的 tasks 数组以触发状态更新 + tasks = tasks.map((task, index) => + index === taskIndex + ? { + ...task, + status, + error, + progress: status === "completed" ? 100 : task.progress, + } + : task, + ); }, getNextQueuedTask: () => tasks.find((task) => task.status === "queued"), getSnapshot: () => createSnapshot(items, tasks, warnings, refreshing), diff --git a/electron/main/backend/update-center/service.ts b/electron/main/backend/update-center/service.ts index f6fc2213..1856aadd 100644 --- a/electron/main/backend/update-center/service.ts +++ b/electron/main/backend/update-center/service.ts @@ -1,3 +1,4 @@ +import { BrowserWindow, ipcMain } from "electron"; import { LEGACY_IGNORE_CONFIG_PATH, applyIgnoredEntries, @@ -5,7 +6,6 @@ import { loadIgnoredEntries, saveIgnoredEntries, } from "./ignore-config"; -import { createTaskRunner, type UpdateCenterTaskRunner } from "./install"; import { createUpdateCenterQueue, type UpdateCenterQueue, @@ -79,11 +79,6 @@ export interface CreateUpdateCenterServiceOptions { loadItems: () => Promise; loadIgnoredEntries?: () => Promise>; saveIgnoredEntries?: (entries: ReadonlySet) => Promise; - createTaskRunner?: ( - queue: UpdateCenterQueue, - superUserCmd?: string, - ) => UpdateCenterTaskRunner; - superUserCmdProvider?: () => Promise; } const getTaskKey = ( @@ -112,19 +107,9 @@ const toState = ( migrationTarget: item.migrationTarget, aptssVersion: item.aptssVersion, })), - tasks: snapshot.tasks.map((task) => ({ - taskKey: getTaskKey(task.item), - packageName: task.pkgname, - source: task.item.source, - localIcon: task.item.localIcon, - remoteIcon: task.item.remoteIcon, - status: task.status, - progress: task.progress, - logs: task.logs.map((log) => ({ ...log })), - errorMessage: task.error ?? "", - })), + tasks: [], // 不再展示任务日志 warnings: [...snapshot.warnings], - hasRunningTasks: snapshot.hasRunningTasks, + hasRunningTasks: false, // 任务不在更新中心执行 }); const normalizeLoadedItems = ( @@ -152,12 +137,8 @@ export const createUpdateCenterService = ( options.saveIgnoredEntries ?? ((entries: ReadonlySet) => saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries)); - const createRunner = - options.createTaskRunner ?? - ((currentQueue: UpdateCenterQueue, superUserCmd?: string) => - createTaskRunner(currentQueue, { superUserCmd })); - let processingPromise: Promise | null = null; - let activeRunner: UpdateCenterTaskRunner | null = null; + + let nextUpdateTaskId = 1; const applyWarning = (message: string): void => { queue.finishRefresh([message]); @@ -167,9 +148,9 @@ export const createUpdateCenterService = ( const emit = (): UpdateCenterServiceState => { const snapshot = getState(); - for (const listener of listeners) { + listeners.forEach((listener) => { listener(snapshot); - } + }); return snapshot; }; @@ -192,47 +173,6 @@ export const createUpdateCenterService = ( } }; - const failQueuedTasks = (message: string): void => { - for (const task of queue.getSnapshot().tasks) { - if (task.status === "queued") { - queue.appendTaskLog(task.id, message); - queue.finishTask(task.id, "failed", message); - } - } - }; - - const ensureProcessing = async (): Promise => { - if (processingPromise) { - return processingPromise; - } - - processingPromise = (async () => { - let superUserCmd = ""; - - try { - superUserCmd = (await options.superUserCmdProvider?.()) ?? ""; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - failQueuedTasks(message); - applyWarning(message); - emit(); - return; - } - - activeRunner = createRunner(queue, superUserCmd); - - while (queue.getNextQueuedTask()) { - await activeRunner.runNextTask(); - emit(); - } - })().finally(() => { - processingPromise = null; - activeRunner = null; - }); - - return processingPromise; - }; - return { open: refresh, refresh, @@ -250,47 +190,63 @@ export const createUpdateCenterService = ( }, async start(taskKeys) { const snapshot = queue.getSnapshot(); - const existingTaskKeys = new Set( - snapshot.tasks - .filter( - (task) => - !["completed", "failed", "cancelled"].includes(task.status), - ) - .map((task) => getTaskKey(task.item)), - ); const selectedItems = snapshot.items.filter( (item) => taskKeys.includes(getTaskKey(item)) && - !item.ignored && - !existingTaskKeys.has(getTaskKey(item)), + !item.ignored, ); if (selectedItems.length === 0) { return; } - for (const item of selectedItems) { - queue.enqueueItem(item); - } - emit(); + // 获取主窗口的 webContents + const mainWindow = BrowserWindow.getAllWindows()[0]; + const webContents = mainWindow?.webContents; - await ensureProcessing(); - }, - async cancel(taskKey) { - const task = queue - .getSnapshot() - .tasks.find((entry) => getTaskKey(entry.item) === taskKey); - - if (!task) { + if (!webContents) { + console.error("No main window found"); return; } - queue.finishTask(task.id, "cancelled", "Cancelled"); - if (["downloading", "installing"].includes(task.status)) { - activeRunner?.cancelActiveTask(); + // 获取当前 items + let currentItems = snapshot.items; + + for (const item of selectedItems) { + const updateTaskId = nextUpdateTaskId++; + + // 构建 metalink URL + const metalinkUrl = item.downloadUrl + ? `${item.downloadUrl}.metalink` + : undefined; + + // 发送到主下载队列 + const installTaskData = { + id: updateTaskId, + pkgname: item.pkgname, + metalinkUrl, + filename: item.fileName, + upgradeOnly: true, + origin: item.source === "apm" ? "apm" : "spark", + retry: false, + }; + + // 通过 IPC 发送到主下载队列 + webContents.send("queue-install", JSON.stringify(installTaskData)); + + // 从更新中心的 items 中移除该应用(不再显示在更新列表中) + currentItems = currentItems.filter((i) => getTaskKey(i) !== getTaskKey(item)); } + + // 更新队列中的 items + queue.setItems(currentItems); + emit(); }, + async cancel(taskKey) { + // 取消功能不再需要通过更新中心,直接忽略 + console.log("Cancel not needed for task:", taskKey); + }, getState, subscribe(listener) { listeners.add(listener); diff --git a/src/components/UpdateCenterModal.vue b/src/components/UpdateCenterModal.vue index 3c22bbc3..76ce5a2f 100644 --- a/src/components/UpdateCenterModal.vue +++ b/src/components/UpdateCenterModal.vue @@ -36,16 +36,13 @@

-
+
-
-
@@ -68,9 +60,7 @@ 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"; @@ -80,8 +70,6 @@ const emit = defineEmits<{ (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<{ diff --git a/src/modules/updateCenter.ts b/src/modules/updateCenter.ts index 7a1ad5e9..14e0b3a4 100644 --- a/src/modules/updateCenter.ts +++ b/src/modules/updateCenter.ts @@ -3,7 +3,10 @@ import { computed, ref, type ComputedRef, type Ref } from "vue"; import type { UpdateCenterItem, UpdateCenterSnapshot, + DownloadItem, } from "@/global/typedefinition"; +import { downloads } from "@/global/downloadStatus"; +import { APM_STORE_BASE_URL } from "@/global/storeConfig"; const EMPTY_SNAPSHOT: UpdateCenterSnapshot = { items: [], @@ -143,21 +146,51 @@ export const createUpdateCenterStore = (): UpdateCenterStore => { }; const startSelected = async (): Promise => { - const taskKeys = getSelectedItems().map((item) => item.taskKey); + const selectedItems = getSelectedItems(); + const taskKeys = selectedItems.map((item) => item.taskKey); if (taskKeys.length === 0) { return; } + // 在前端创建下载项,这样用户能在下载列表中看到更新任务 + const arch = window.apm_store.arch || "amd64"; + let downloadIdCounter = downloads.value.length > 0 ? Math.max(...downloads.value.map(d => d.id)) + 1 : 1; + + selectedItems.forEach((item) => { + // 检查任务是否已存在 + if (!downloads.value.find(d => d.pkgname === item.packageName && d.origin === (item.source === "apm" ? "apm" : "spark"))) { + const finalArch = item.source === "apm" ? `${arch}-apm` : `${arch}-store`; + const download: DownloadItem = { + id: downloadIdCounter++, + name: item.displayName, + pkgname: item.packageName, + version: item.newVersion, + icon: `${APM_STORE_BASE_URL}/${finalArch}/unknown/${item.packageName}/icon.png`, + origin: item.source === "apm" ? "apm" : "spark", + status: "queued", + progress: 0, + downloadedSize: 0, + totalSize: item.size || 0, + speed: 0, + timeRemaining: 0, + startTime: Date.now(), + logs: [{ time: Date.now(), message: "开始更新..." }], + source: "Update Center", + retry: false, + upgradeOnly: true, + filename: item.fileName, + metalinkUrl: item.downloadUrl ? `${item.downloadUrl}.metalink` : undefined, + }; + downloads.value.push(download); + } + }); + await window.updateCenter.start(taskKeys); }; const requestClose = (): void => { - if (snapshot.value.hasRunningTasks) { - showCloseConfirm.value = true; - return; - } - + // 直接关闭,不需要确认,因为任务在主下载队列中执行 closeNow(); };