diff --git a/electron/main/backend/update-center/index.ts b/electron/main/backend/update-center/index.ts index 659205f0..f5e91a4a 100644 --- a/electron/main/backend/update-center/index.ts +++ b/electron/main/backend/update-center/index.ts @@ -14,6 +14,7 @@ import { createUpdateCenterService, type UpdateCenterIgnorePayload, type UpdateCenterService, + type UpdateCenterStartTask, } from "./service"; import type { UpdateCenterItem } from "./types"; @@ -435,8 +436,8 @@ export const registerUpdateCenterIpc = ( "update-center-unignore", (_event, payload: UpdateCenterIgnorePayload) => service.unignore(payload), ); - ipc.handle("update-center-start", (_event, taskKeys: string[]) => - service.start(taskKeys), + ipc.handle("update-center-start", (_event, tasks: UpdateCenterStartTask[]) => + service.start(tasks), ); ipc.handle("update-center-cancel", (_event, taskKey: string) => service.cancel(taskKey), diff --git a/electron/main/backend/update-center/service.ts b/electron/main/backend/update-center/service.ts index ef3abb0f..b8fb9ec3 100644 --- a/electron/main/backend/update-center/service.ts +++ b/electron/main/backend/update-center/service.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, ipcMain } from "electron"; +import { BrowserWindow } from "electron"; import { LEGACY_IGNORE_CONFIG_PATH, applyIgnoredEntries, @@ -8,7 +8,6 @@ import { } from "./ignore-config"; import { createUpdateCenterQueue, - type UpdateCenterQueue, type UpdateCenterQueueSnapshot, } from "./queue"; import type { UpdateCenterItem, UpdateSource } from "./types"; @@ -62,12 +61,17 @@ export interface UpdateCenterIgnorePayload { newVersion: string; } +export interface UpdateCenterStartTask { + taskKey: string; + id: number; +} + export interface UpdateCenterService { open: () => Promise; refresh: () => Promise; ignore: (payload: UpdateCenterIgnorePayload) => Promise; unignore: (payload: UpdateCenterIgnorePayload) => Promise; - start: (taskKeys: string[]) => Promise; + start: (tasks: UpdateCenterStartTask[]) => Promise; cancel: (taskKey: string) => Promise; getState: () => UpdateCenterServiceState; subscribe: ( @@ -138,8 +142,6 @@ export const createUpdateCenterService = ( ((entries: ReadonlySet) => saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries)); - let nextUpdateTaskId = 1; - const applyWarning = (message: string): void => { queue.finishRefresh([message]); }; @@ -188,10 +190,11 @@ export const createUpdateCenterService = ( await saveIgnored(entries); await refresh(); }, - async start(taskKeys) { + async start(tasks) { const snapshot = queue.getSnapshot(); + const taskIdByKey = new Map(tasks.map((task) => [task.taskKey, task.id])); const selectedItems = snapshot.items.filter( - (item) => taskKeys.includes(getTaskKey(item)) && !item.ignored, + (item) => taskIdByKey.has(getTaskKey(item)) && !item.ignored, ); if (selectedItems.length === 0) { @@ -211,7 +214,10 @@ export const createUpdateCenterService = ( let currentItems = snapshot.items; for (const item of selectedItems) { - const updateTaskId = nextUpdateTaskId++; + const updateTaskId = taskIdByKey.get(getTaskKey(item)); + if (updateTaskId === undefined) { + continue; + } // 构建 metalink URL const metalinkUrl = item.downloadUrl diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 9b663c41..eef63377 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -33,6 +33,11 @@ type UpdateCenterSnapshot = { hasRunningTasks: boolean; }; +type UpdateCenterStartTask = { + taskKey: string; + id: number; +}; + type IpcRendererFacade = { on: typeof ipcRenderer.on; off: typeof ipcRenderer.off; @@ -98,8 +103,8 @@ contextBridge.exposeInMainWorld("updateCenter", { packageName: string; newVersion: string; }): Promise => ipcRenderer.invoke("update-center-unignore", payload), - start: (taskKeys: string[]): Promise => - ipcRenderer.invoke("update-center-start", taskKeys), + start: (tasks: UpdateCenterStartTask[]): Promise => + ipcRenderer.invoke("update-center-start", tasks), cancel: (taskKey: string): Promise => ipcRenderer.invoke("update-center-cancel", taskKey), getState: (): Promise => diff --git a/src/__tests__/unit/update-center/service-id-forwarding.test.ts b/src/__tests__/unit/update-center/service-id-forwarding.test.ts new file mode 100644 index 00000000..f69ce620 --- /dev/null +++ b/src/__tests__/unit/update-center/service-id-forwarding.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createUpdateCenterService } from "../../../../electron/main/backend/update-center/service"; + +const electronMock = vi.hoisted(() => ({ + getAllWindows: vi.fn(), +})); + +vi.mock("electron", () => ({ + BrowserWindow: { + getAllWindows: electronMock.getAllWindows, + }, +})); + +describe("update-center service id forwarding", () => { + beforeEach(() => { + electronMock.getAllWindows.mockReset(); + }); + + it("forwards renderer-assigned ids into queue-install payloads", async () => { + const send = vi.fn(); + electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]); + + const service = createUpdateCenterService({ + loadItems: async () => [ + { + pkgname: "spark-weather", + source: "aptss", + currentVersion: "1.0.0", + nextVersion: "2.0.0", + fileName: "spark-weather.deb", + downloadUrl: "https://example.com/spark-weather.deb", + }, + ], + }); + + await service.refresh(); + await service.start([{ taskKey: "aptss:spark-weather", id: -1 }]); + + expect(send).toHaveBeenCalledWith( + "queue-install", + JSON.stringify({ + id: -1, + pkgname: "spark-weather", + metalinkUrl: "https://example.com/spark-weather.deb.metalink", + filename: "spark-weather.deb", + upgradeOnly: true, + origin: "spark", + retry: false, + }), + ); + }); +}); diff --git a/src/__tests__/unit/update-center/store.test.ts b/src/__tests__/unit/update-center/store.test.ts index 873aefda..a5ea8cd0 100644 --- a/src/__tests__/unit/update-center/store.test.ts +++ b/src/__tests__/unit/update-center/store.test.ts @@ -96,7 +96,12 @@ describe("updateCenter store", () => { store.toggleSelection("apm:spark-clock"); await store.startSelected(); - expect(start).toHaveBeenCalledWith(["aptss:spark-weather"]); + expect(start).toHaveBeenCalledWith([ + { + taskKey: "aptss:spark-weather", + id: downloads.value[0]?.id, + }, + ]); }); it("uses remoteIcon when adding update tasks to the download queue", async () => { @@ -127,6 +132,45 @@ describe("updateCenter store", () => { ); }); + it("passes the renderer download id through to update-center start", async () => { + downloads.value = [ + { + id: 5, + name: "Spark Notes", + pkgname: "spark-notes", + version: "1.0.0", + icon: "https://example.com/icons/spark-notes.png", + origin: "spark", + status: "queued", + progress: 0, + downloadedSize: 0, + totalSize: 1024, + speed: 0, + timeRemaining: 0, + startTime: Date.now(), + logs: [], + source: "APM Store", + retry: false, + }, + ]; + const snapshot = createSnapshot(); + open.mockResolvedValue(snapshot); + const store = createUpdateCenterStore(); + + await store.open(); + store.toggleSelection("aptss:spark-weather"); + await store.startSelected(); + + expect(downloads.value).toHaveLength(2); + expect(downloads.value[1]?.id).toBeLessThan(0); + expect(start).toHaveBeenCalledWith([ + { + taskKey: "aptss:spark-weather", + id: downloads.value[1]?.id, + }, + ]); + }); + it("blocks close requests while the snapshot reports running tasks", () => { const store = createUpdateCenterStore(); store.isOpen.value = true; diff --git a/src/global/downloadStatus.ts b/src/global/downloadStatus.ts index 202333bc..03e44396 100644 --- a/src/global/downloadStatus.ts +++ b/src/global/downloadStatus.ts @@ -3,6 +3,34 @@ import type { DownloadItem, DownloadItemStatus } from "./typedefinition"; export const downloads = ref([]); +let nextDownloadId = 1; + +export function getNextDownloadId(): number { + if (downloads.value.length > 0) { + nextDownloadId = Math.max( + nextDownloadId, + Math.max(...downloads.value.map((item) => item.id)) + 1, + ); + } + + const downloadId = nextDownloadId; + nextDownloadId += 1; + + return downloadId; +} + +export function getNextUpdateDownloadId(): number { + const negativeIds = downloads.value + .map((item) => item.id) + .filter((id) => id < 0); + + if (negativeIds.length === 0) { + return -1; + } + + return Math.min(...negativeIds) - 1; +} + export function removeDownloadItem(pkgname: string) { const list = downloads.value; for (let i = list.length - 1; i >= 0; i -= 1) { diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index d2246253..39a783ce 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -165,6 +165,11 @@ export interface UpdateCenterTaskState { errorMessage: string; } +export interface UpdateCenterStartTask { + taskKey: string; + id: number; +} + export interface UpdateCenterSnapshot { items: UpdateCenterItem[]; tasks: UpdateCenterTaskState[]; @@ -183,7 +188,7 @@ export interface UpdateCenterBridge { packageName: string; newVersion: string; }) => Promise; - start: (taskKeys: string[]) => Promise; + start: (tasks: UpdateCenterStartTask[]) => Promise; cancel: (taskKey: string) => Promise; getState: () => Promise; onState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void; diff --git a/src/modules/processInstall.ts b/src/modules/processInstall.ts index c1d22aec..350661e1 100644 --- a/src/modules/processInstall.ts +++ b/src/modules/processInstall.ts @@ -7,7 +7,7 @@ import { currentAppApmInstalled, } from "../global/storeConfig"; import { APM_STORE_BASE_URL } from "../global/storeConfig"; -import { downloads } from "../global/downloadStatus"; +import { downloads, getNextDownloadId } from "../global/downloadStatus"; import { InstallLog, @@ -18,7 +18,6 @@ import { } from "../global/typedefinition"; import axios from "axios"; -let downloadIdCounter = 0; const logger = pino({ name: "processInstall.ts" }); export const handleInstall = async (appObj?: App) => { @@ -51,14 +50,14 @@ export const handleInstall = async (appObj?: App) => { return; } - downloadIdCounter += 1; // 创建下载任务 const arch = window.apm_store.arch || "amd64"; const finalArch = targetApp.origin === "spark" ? `${arch}-store` : `${arch}-apm`; + const downloadId = getNextDownloadId(); const download: DownloadItem = { - id: downloadIdCounter, + id: downloadId, name: targetApp.name, pkgname: targetApp.pkgname, version: targetApp.version, @@ -140,12 +139,12 @@ export const handleUpgrade = async (app: App) => { return; } - downloadIdCounter += 1; const arch = window.apm_store.arch || "amd64"; const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`; + const downloadId = getNextDownloadId(); const download: DownloadItem = { - id: downloadIdCounter, + id: downloadId, name: app.name, pkgname: app.pkgname, version: app.version, diff --git a/src/modules/updateCenter.ts b/src/modules/updateCenter.ts index c2b2ec7b..f2a5c158 100644 --- a/src/modules/updateCenter.ts +++ b/src/modules/updateCenter.ts @@ -4,8 +4,9 @@ import type { UpdateCenterItem, UpdateCenterSnapshot, DownloadItem, + UpdateCenterStartTask, } from "@/global/typedefinition"; -import { downloads } from "@/global/downloadStatus"; +import { downloads, getNextUpdateDownloadId } from "@/global/downloadStatus"; import { APM_STORE_BASE_URL } from "@/global/storeConfig"; const EMPTY_SNAPSHOT: UpdateCenterSnapshot = { @@ -88,12 +89,18 @@ export const createUpdateCenterStore = (): UpdateCenterStore => { const allSelected = computed(() => { const selectable = selectableItems.value; - return selectable.length > 0 && selectable.every((item) => selectedTaskKeys.value.has(item.taskKey)); + return ( + selectable.length > 0 && + selectable.every((item) => selectedTaskKeys.value.has(item.taskKey)) + ); }); const someSelected = computed(() => { const selectable = selectableItems.value; - return selectable.length > 0 && selectable.some((item) => selectedTaskKeys.value.has(item.taskKey)); + return ( + selectable.length > 0 && + selectable.some((item) => selectedTaskKeys.value.has(item.taskKey)) + ); }); const handleState = (nextSnapshot: UpdateCenterSnapshot): void => { @@ -173,18 +180,13 @@ export const createUpdateCenterStore = (): UpdateCenterStore => { const startSelected = async (): Promise => { const selectedItems = getSelectedItems(); - const taskKeys = selectedItems.map((item) => item.taskKey); - - if (taskKeys.length === 0) { + if (selectedItems.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; + const startTasks: UpdateCenterStartTask[] = []; selectedItems.forEach((item) => { // 检查任务是否已存在 @@ -200,8 +202,9 @@ export const createUpdateCenterStore = (): UpdateCenterStore => { const icon = item.remoteIcon || `${APM_STORE_BASE_URL}/${finalArch}/unknown/${item.packageName}/icon.png`; + const downloadId = getNextUpdateDownloadId(); const download: DownloadItem = { - id: downloadIdCounter++, + id: downloadId, name: item.displayName, pkgname: item.packageName, version: item.newVersion, @@ -224,10 +227,18 @@ export const createUpdateCenterStore = (): UpdateCenterStore => { : undefined, }; downloads.value.push(download); + startTasks.push({ + taskKey: item.taskKey, + id: downloadId, + }); } }); - await window.updateCenter.start(taskKeys); + if (startTasks.length === 0) { + return; + } + + await window.updateCenter.start(startTasks); }; const requestClose = (): void => {