diff --git a/docs/superpowers/plans/2026-04-12-update-center-migration-strategy.md b/docs/superpowers/plans/2026-04-12-update-center-migration-strategy.md new file mode 100644 index 00000000..3fb84d24 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-update-center-migration-strategy.md @@ -0,0 +1,529 @@ +# Update Center Migration Strategy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make update-center behavior follow installed-source-aware rules, including aptss-to-apm migration as a single visible update that removes the aptss package before installing the apm version. + +**Architecture:** Keep the existing update-center pipeline, but change it at three narrow seams: source merging in `query.ts`, task payload creation in `service.ts`, and migration execution in the main-process install path. The renderer keeps showing update items and download queue entries, while the main process becomes the only place that performs the ordered `aptss remove -> apm install` migration. + +**Tech Stack:** TypeScript, Electron IPC, Vue 3, Vitest + +--- + +### Task 1: Installed-Source Merge Rules + +**Files:** + +- Modify: `electron/main/backend/update-center/query.ts:325-374` +- Test: `src/__tests__/unit/update-center/query.test.ts` + +- [ ] **Step 1: Write the failing merge-rule tests** + +Add these tests to `src/__tests__/unit/update-center/query.test.ts` next to the existing `mergeUpdateSources()` coverage: + +```ts +it("returns only the migration item when only aptss is installed and apm has a higher version", () => { + 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", + }, + ]); +}); + +it("returns only the aptss item when only aptss is installed and apm is not newer", () => { + const merged = mergeUpdateSources( + [ + { + pkgname: "spark-notes", + source: "aptss", + currentVersion: "1.0.0", + nextVersion: "2.0.0", + }, + ], + [ + { + pkgname: "spark-notes", + source: "apm", + currentVersion: "1.0.0", + nextVersion: "1.5.0", + }, + ], + new Map([["spark-notes", { aptss: true, apm: false }]]), + ); + + expect(merged).toEqual([ + { + pkgname: "spark-notes", + source: "aptss", + currentVersion: "1.0.0", + nextVersion: "2.0.0", + }, + ]); +}); + +it("returns only the apm item when only apm is installed", () => { + const merged = mergeUpdateSources( + [ + { + pkgname: "spark-player", + source: "aptss", + currentVersion: "1.0.0", + nextVersion: "2.0.0", + }, + ], + [ + { + pkgname: "spark-player", + source: "apm", + currentVersion: "1.1.0", + nextVersion: "3.0.0", + }, + ], + new Map([["spark-player", { aptss: false, apm: true }]]), + ); + + expect(merged).toEqual([ + { + pkgname: "spark-player", + source: "apm", + currentVersion: "1.1.0", + nextVersion: "3.0.0", + }, + ]); +}); + +it("returns both items when aptss and apm are both installed", () => { + const merged = mergeUpdateSources( + [ + { + pkgname: "spark-browser", + source: "aptss", + currentVersion: "10.0", + nextVersion: "11.0", + }, + ], + [ + { + pkgname: "spark-browser", + source: "apm", + currentVersion: "11.0", + nextVersion: "12.0", + }, + ], + new Map([["spark-browser", { aptss: true, apm: true }]]), + ); + + expect(merged).toEqual([ + { + pkgname: "spark-browser", + source: "aptss", + currentVersion: "10.0", + nextVersion: "11.0", + }, + { + pkgname: "spark-browser", + source: "apm", + currentVersion: "11.0", + nextVersion: "12.0", + }, + ]); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- --run src/__tests__/unit/update-center/query.test.ts` +Expected: FAIL because the current implementation still returns both the migration item and the aptss item for the aptss-only migration case, and still returns both sources in the apm-only case. + +- [ ] **Step 3: Write the minimal merge logic** + +Update `electron/main/backend/update-center/query.ts` so `mergeUpdateSources()` uses installed-source-aware branching instead of unconditional double inclusion. Replace the body with this implementation shape: + +```ts +export const mergeUpdateSources = ( + aptssItems: UpdateCenterItem[], + apmItems: UpdateCenterItem[], + installedSources: Map, +): UpdateCenterItem[] => { + const aptssMap = new Map(aptssItems.map((item) => [item.pkgname, item])); + const apmMap = new Map(apmItems.map((item) => [item.pkgname, item])); + const pkgnames = new Set([...aptssMap.keys(), ...apmMap.keys()]); + const merged: UpdateCenterItem[] = []; + + for (const pkgname of pkgnames) { + const aptssItem = aptssMap.get(pkgname); + const apmItem = apmMap.get(pkgname); + const installedState = installedSources.get(pkgname); + + if (installedState?.aptss === true && installedState.apm === false) { + if (aptssItem && apmItem) { + if (compareVersions(apmItem.nextVersion, aptssItem.nextVersion) > 0) { + merged.push({ + ...apmItem, + isMigration: true, + migrationSource: "aptss", + migrationTarget: "apm", + aptssVersion: aptssItem.nextVersion, + }); + } else { + merged.push(aptssItem); + } + continue; + } + + if (aptssItem) { + merged.push(aptssItem); + } + continue; + } + + if (installedState?.aptss === false && installedState.apm === true) { + if (apmItem) { + merged.push(apmItem); + } + continue; + } + + if (installedState?.aptss === true && installedState.apm === true) { + if (aptssItem) merged.push(aptssItem); + if (apmItem) merged.push(apmItem); + continue; + } + + if (aptssItem) merged.push(aptssItem); + if (apmItem) merged.push(apmItem); + } + + return merged; +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- --run src/__tests__/unit/update-center/query.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/__tests__/unit/update-center/query.test.ts electron/main/backend/update-center/query.ts +git commit -m "fix(update-center): apply installed-source merge rules" +``` + +### Task 2: Migration Payload and Main-Process Execution + +**Files:** + +- Modify: `electron/main/backend/update-center/service.ts:191-245` +- Modify: `src/global/typedefinition.ts:29-54` +- Modify: `src/modules/updateCenter.ts:148-205` +- Modify: `electron/main/backend/install-manager.ts:251-299` +- Test: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts` +- Test: `src/__tests__/unit/update-center/task-runner.test.ts` + +- [ ] **Step 1: Write the failing IPC payload test** + +Add this test to `src/__tests__/unit/update-center/registerUpdateCenter.test.ts` near the existing `service.start()` coverage: + +```ts +it("sends migration metadata to the main install queue", async () => { + const send = vi.fn(); + electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]); + + const service = createUpdateCenterService({ + loadItems: async () => [ + { + ...createItem(), + source: "apm", + isMigration: true, + migrationSource: "aptss", + migrationTarget: "apm", + fileName: "spark-weather_3.0.0_amd64.deb", + downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb", + }, + ], + }); + + await service.refresh(); + await service.start(["apm:spark-weather"]); + + expect(send).toHaveBeenCalledWith( + "queue-install", + JSON.stringify( + expect.objectContaining({ + pkgname: "spark-weather", + origin: "apm", + upgradeOnly: true, + isMigration: true, + migrationSource: "aptss", + migrationTarget: "apm", + }), + ), + ); +}); +``` + +- [ ] **Step 2: Write the failing migration install test** + +Add this test to `src/__tests__/unit/update-center/task-runner.test.ts` after the direct install command tests: + +```ts +it("runs aptss remove before apm ssinstall for migration items", async () => { + childProcessMock.spawnCalls.length = 0; + + await installUpdateItem({ + item: { + ...createApmItem(), + isMigration: true, + migrationSource: "aptss", + migrationTarget: "apm", + }, + 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", + "aptss", + "remove", + "spark-player", + ], + }, + { + command: "/usr/bin/pkexec", + args: [ + "/opt/spark-store/extras/shell-caller.sh", + "apm", + "ssinstall", + "/tmp/spark-player.deb", + ], + }, + ]); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm run test -- --run src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/task-runner.test.ts` +Expected: FAIL because the queue-install payload does not include migration metadata yet, and the install path still runs only the apm install command. + +- [ ] **Step 4: Extend the task payload types** + +Add these optional fields to `DownloadItem`-adjacent transport types in `src/global/typedefinition.ts` or the local payload interface that already carries queue-install data: + +```ts + isMigration?: boolean; + migrationSource?: "aptss" | "apm"; + migrationTarget?: "aptss" | "apm"; +``` + +If the queue payload is not typed centrally, create a narrow local type in `electron/main/backend/update-center/service.ts` and a matching parsing shape in `electron/main/backend/install-manager.ts`. + +- [ ] **Step 5: Send migration metadata from the update-center service** + +Change `installTaskData` in `electron/main/backend/update-center/service.ts` to include the migration fields when present: + +```ts +const installTaskData = { + id: updateTaskId, + pkgname: item.pkgname, + metalinkUrl, + filename: item.fileName, + upgradeOnly: true, + origin: item.source === "apm" ? "apm" : "spark", + retry: false, + isMigration: item.isMigration === true, + migrationSource: item.migrationSource, + migrationTarget: item.migrationTarget, +}; +``` + +- [ ] **Step 6: Preserve migration state in the renderer queue item** + +Extend the temporary queue item created in `src/modules/updateCenter.ts` so migration items show up as migration work instead of generic updates: + +```ts + logs: [ + { + time: Date.now(), + message: item.isMigration === true ? "开始迁移到 APM..." : "开始更新...", + }, + ], +``` + +Also carry the same optional migration flags if the renderer-side queue type supports them. + +- [ ] **Step 7: Implement ordered migration execution in the main process** + +In the main install path used by update-center file installs, add a migration branch before the normal `origin === "apm"` handling. The shape should be: + +```ts +if (isMigration === true && migrationSource === "aptss" && origin === "apm") { + const removeCommand = superUserCmd || SHELL_CALLER_PATH; + const removeParams: string[] = []; + if (superUserCmd) { + removeParams.push(SHELL_CALLER_PATH); + } + removeParams.push("aptss", "remove", pkgname); + + await runInstallCommand({ + command: removeCommand, + args: removeParams, + webContents, + id, + stageLabel: "迁移卸载旧版本", + }); +} +``` + +Then fall through to the existing apm install branch so the next command remains: + +```ts +execParams.push("apm"); +execParams.push("ssinstall", `${downloadDir}/${filename}`); +``` + +Use the existing install-log/install-complete reporting path rather than inventing a second event system. + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `npm run test -- --run src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/task-runner.test.ts` +Expected: PASS for the new migration payload and command-order tests + +- [ ] **Step 9: Commit** + +```bash +git add electron/main/backend/update-center/service.ts src/global/typedefinition.ts src/modules/updateCenter.ts electron/main/backend/install-manager.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/task-runner.test.ts +git commit -m "feat(update-center): run aptss-to-apm migrations" +``` + +### Task 3: Migration Confirmation Copy and Renderer Regression + +**Files:** + +- Modify: `src/components/update-center/UpdateCenterMigrationConfirm.vue` +- Test: `src/__tests__/unit/update-center/UpdateCenterModal.test.ts` + +- [ ] **Step 1: Write the failing migration-copy test** + +Update `src/__tests__/unit/update-center/UpdateCenterModal.test.ts` so the migration confirmation assertion expects the new copy: + +```ts +it("renders migration confirmation copy explaining aptss removal and apm install", () => { + const store = createStore({ hasRunningTasks: false }); + store.showMigrationConfirm.value = true; + + render(UpdateCenterModal, { + props: { + show: true, + store, + }, + }); + + expect(screen.getByText("迁移确认")).toBeTruthy(); + expect( + screen.getByText(/会先卸载现有 aptss 版本,再安装 APM 版本/), + ).toBeTruthy(); + expect(screen.getByText(/后续更新将由 APM 管理/)).toBeTruthy(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterModal.test.ts` +Expected: FAIL because the current modal copy only says that some deb updates will migrate to APM. + +- [ ] **Step 3: Update the modal copy with the approved behavior** + +Change the body text in `src/components/update-center/UpdateCenterMigrationConfirm.vue` to this: + +```vue +

+ 该应用将从传统 aptss 管理迁移到 APM 管理。迁移过程会先卸载现有 aptss 版本,再安装 APM 版本;迁移完成后,后续更新将由 APM 管理。 +

+``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterModal.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/components/update-center/UpdateCenterMigrationConfirm.vue src/__tests__/unit/update-center/UpdateCenterModal.test.ts +git commit -m "docs(update-center): clarify migration confirmation" +``` + +### Task 4: Final Verification + +**Files:** + +- Modify: none +- Test: `src/__tests__/unit/update-center/query.test.ts` +- Test: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts` +- Test: `src/__tests__/unit/update-center/task-runner.test.ts` +- Test: `src/__tests__/unit/update-center/UpdateCenterModal.test.ts` + +- [ ] **Step 1: Run focused regression suite** + +Run: + +```bash +npm run test -- --run src/__tests__/unit/update-center/query.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/task-runner.test.ts src/__tests__/unit/update-center/UpdateCenterModal.test.ts +``` + +Expected: PASS + +- [ ] **Step 2: Run lint if the touched files are lint-clean** + +Run: `npm run lint` +Expected: either PASS or the same known unrelated pre-existing lint failures already present in the branch. Do not claim a clean lint run unless the command output is actually clean. + +- [ ] **Step 3: Review the final diff** + +Run: + +```bash +git diff -- electron/main/backend/update-center/query.ts electron/main/backend/update-center/service.ts electron/main/backend/install-manager.ts src/modules/updateCenter.ts src/components/update-center/UpdateCenterMigrationConfirm.vue src/__tests__/unit/update-center/query.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/task-runner.test.ts src/__tests__/unit/update-center/UpdateCenterModal.test.ts +``` + +Expected: diff only covers merge rules, migration payload/execution, and migration confirmation copy. + +- [ ] **Step 4: Commit final verification state if needed** + +```bash +git status --short +``` + +If uncommitted changes remain from verification-only edits, either commit them with a focused message or fold them into the last task commit before handing off. diff --git a/docs/superpowers/specs/2026-04-12-update-center-migration-strategy-design.md b/docs/superpowers/specs/2026-04-12-update-center-migration-strategy-design.md new file mode 100644 index 00000000..1119cfc4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-update-center-migration-strategy-design.md @@ -0,0 +1,169 @@ +# 更新中心迁移更新策略设计 + +## 背景 + +当前更新中心会同时拉取 `aptss` 和 `apm` 的可更新列表,并按包名合并展示。现有行为中,双源同名更新通常会显示两条记录;即使标记了“迁移”,也不会真正执行“卸载 aptss 后安装 apm”的迁移流程。 + +目标是把更新策略调整为以已安装来源为主,并在 `aptss -> apm` 迁移场景中提供明确、单一且可确认的更新入口。 + +## 目标行为 + +### 1. 仅安装了 aptss 版本 + +- 同时检查 `aptss` 和 `apm` 是否有同名更新。 +- 如果只有 `aptss` 有更新:显示一条普通 `aptss` 更新记录。 +- 如果 `apm` 也有同名更新,且 `apm` 的目标版本高于 `aptss`: + - 只显示一条迁移更新记录。 + - 该记录的展示语义为“将迁移到 APM 管理”。 + - 不再显示对应的普通 `aptss` 更新记录。 +- 用户确认迁移后,执行: + 1. `shell-caller.sh aptss remove ` + 2. 安装 `apm` 版本。 + +### 2. 仅安装了 apm 版本 + +- 只检查并展示 `apm` 的同名更新。 +- 即使 `aptss` 存在同名更新,也不在更新中心中展示。 + +### 3. 同时安装了 aptss 与 apm 版本 + +- 同时展示两条更新记录。 +- `aptss` 记录更新 `aptss` 安装位置。 +- `apm` 记录更新 `apm` 安装位置。 +- 两条记录互不替代,也不触发迁移逻辑。 + +## 数据模型调整 + +### UpdateCenterItem + +保留现有字段,并继续使用以下迁移字段: + +- `isMigration?: boolean` +- `migrationSource?: "aptss" | "apm"` +- `migrationTarget?: "aptss" | "apm"` +- `aptssVersion?: string` + +迁移记录仍以 `source: "apm"` 表示最终安装来源,但其语义从“推荐迁移”改为“唯一展示的迁移更新入口”。 + +## 列表合并规则 + +更新 `mergeUpdateSources()` 的逻辑,使其按安装来源状态决定展示结果,而不是单纯把双源结果并列展示。 + +### 情况 A:仅 aptss 安装 + +条件:`installedState.aptss === true && installedState.apm === false` + +- 若只有 `aptss` 更新:返回 `aptss` 记录。 +- 若只有 `apm` 更新:不展示该条记录。 +- 若两者都有: + - 如果 `apm.nextVersion > aptss.nextVersion`: + - 只返回一条迁移记录,基于 `apmItem` 构造。 + - 设置 `isMigration: true`、`migrationSource: "aptss"`、`migrationTarget: "apm"`。 + - 保存 `aptssVersion` 供 UI 展示。 + - 否则:只返回 `aptss` 记录。 + +### 情况 B:仅 apm 安装 + +条件:`installedState.aptss === false && installedState.apm === true` + +- 若 `apm` 有更新:返回 `apm` 记录。 +- 忽略同名 `aptss` 更新。 + +### 情况 C:同时安装 aptss 与 apm + +条件:`installedState.aptss === true && installedState.apm === true` + +- 若两者都有更新:同时返回两条记录。 +- 若只有其中一方有更新:只返回对应来源的记录。 + +### 情况 D:未识别安装来源 + +- 保持保守策略:按现有回退方式展示已有更新项。 +- 这个分支仅用于防止源状态解析异常时整个列表为空。 + +## 前端交互 + +### 迁移确认弹窗 + +当用户选择的更新项中包含 `isMigration === true` 的记录时,继续弹出迁移确认框。 + +文案需要明确以下信息: + +- 该应用将从传统 `aptss` 管理迁移到 `APM` 管理。 +- 迁移过程会先卸载现有 `aptss` 版本,再安装 `APM` 版本。 +- 迁移后,该应用后续更新将由 `APM` 管理。 + +### 下载队列表现 + +- 迁移任务加入下载队列时,名称与图标沿用更新中心项。 +- 队列项可继续显示为 `origin: "apm"`,因为最终安装目标是 `apm`。 +- 日志首条应明确表明这是迁移更新,而不是普通更新。 + +## 执行链路 + +### 当前问题 + +当前更新中心点击更新后,只是把任务交给现有下载/安装队列;迁移任务并不会真正先卸载 `aptss`。 + +### 新执行方式 + +对于 `isMigration === true` 的任务: + +1. 创建更新任务并进入现有下载/安装队列。 +2. 在主进程的更新中心执行链路中识别该任务为迁移任务。 +3. 先调用: + - `shell-caller.sh aptss remove ` +4. 若卸载成功,再继续现有 `apm` 安装流程。 +5. 若卸载失败: + - 不进入 `apm` 安装。 + - 将任务标记为失败。 + - 将错误信息推送到下载日志与更新中心状态。 + +### 失败处理 + +- `aptss remove` 失败: + - 整个迁移任务失败。 + - 保留用户现有安装状态,不做后续安装。 +- `aptss remove` 成功但 `apm` 安装失败: + - 任务失败。 + - 不做自动回滚。 + - 在日志中明确说明:旧版本已卸载,新版本安装失败,需要用户重试。 + +本次实现不加入自动回滚,避免在失败分支里引入额外高风险操作。 + +## 受影响模块 + +- `electron/main/backend/update-center/query.ts` + - 重写合并规则。 +- `electron/main/backend/update-center/service.ts` + - 保持迁移标记透传,并为后续执行提供足够字段。 +- `electron/main/backend/install-manager.ts` 或迁移任务真正进入的主进程安装执行层 + - 为迁移任务增加“先 aptss remove,再 apm install”的顺序执行。 +- `src/components/update-center/UpdateCenterMigrationConfirm.vue` + - 更新提示文案。 +- `src/modules/updateCenter.ts` + - 保持迁移项进入下载队列时的展示信息正确。 + +## 测试策略 + +需要新增或调整以下测试: + +- `mergeUpdateSources()` 单元测试: + - 仅 aptss 安装 + apm 更高版本 -> 仅返回一条迁移记录。 + - 仅 aptss 安装 + apm 不更高 -> 仅返回 aptss 记录。 + - 仅 apm 安装 + 双源同名更新 -> 仅返回 apm 记录。 + - 双方都安装 + 双源同名更新 -> 返回两条记录。 +- 更新中心服务/IPC 测试: + - 迁移任务被正确标记并透传。 +- 安装执行测试: + - 迁移任务先执行 `shell-caller.sh aptss remove `。 + - 卸载失败时不会继续安装 `apm`。 + - 卸载成功后继续执行 `apm` 安装流程。 +- 前端测试: + - 迁移弹窗文案与触发条件正确。 + +## 非目标 + +- 不实现迁移失败后的自动回滚。 +- 不修改普通 `aptss` 或普通 `apm` 更新的现有安装流程。 +- 不改变“双安装”场景下两条记录并存的行为。 diff --git a/electron/main/backend/shared-installer.ts b/electron/main/backend/shared-installer.ts index d1f31c2a..851ba9ba 100644 --- a/electron/main/backend/shared-installer.ts +++ b/electron/main/backend/shared-installer.ts @@ -244,7 +244,7 @@ export const installPackage = async ({ filePath, "--delete-after-install", "--no-create-desktop-entry", - "--native" + "--native", ); } else { // APM diff --git a/electron/main/backend/update-center/download.ts b/electron/main/backend/update-center/download.ts index b9be8326..523bd49c 100644 --- a/electron/main/backend/update-center/download.ts +++ b/electron/main/backend/update-center/download.ts @@ -27,7 +27,7 @@ export const runAria2Download = async ({ // 使用与商店安装相同的下载逻辑 const metalinkUrl = `${item.downloadUrl}.metalink`; - + const result = await downloadPackage({ pkgname: item.pkgname, metalinkUrl, 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/install.ts b/electron/main/backend/update-center/install.ts index 5660b541..03d6ee86 100644 --- a/electron/main/backend/update-center/install.ts +++ b/electron/main/backend/update-center/install.ts @@ -67,7 +67,7 @@ export const installUpdateItem = async ({ // 使用与商店安装相同的安装逻辑 const origin = item.source === "apm" ? "apm" : "spark"; - + await installPackage({ pkgname: item.pkgname, filePath, diff --git a/electron/main/backend/update-center/service.ts b/electron/main/backend/update-center/service.ts index ef3abb0f..5557b1b3 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) { + continue; + } // 构建 metalink URL const metalinkUrl = item.downloadUrl diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 9b663c41..13421170 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -41,6 +41,10 @@ type IpcRendererFacade = { }; type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void; +type UpdateCenterStartTask = { + taskKey: string; + id: number; +}; const updateCenterStateListeners = new Map< UpdateCenterStateListener, @@ -98,8 +102,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/downloadStatus.test.ts b/src/__tests__/unit/downloadStatus.test.ts index d87aa342..21843e35 100644 --- a/src/__tests__/unit/downloadStatus.test.ts +++ b/src/__tests__/unit/downloadStatus.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { downloads, removeDownloadItem } from "@/global/downloadStatus"; +import { + downloads, + getNextDownloadId, + removeDownloadItem, +} from "@/global/downloadStatus"; import type { DownloadItem } from "@/global/typedefinition"; describe("downloadStatus", () => { @@ -98,5 +102,16 @@ describe("downloadStatus", () => { "app-3", ]); }); + + it("should not reuse ids after earlier tasks are removed", () => { + downloads.value.push(createMockDownload(1, "app-1")); + + const secondId = getNextDownloadId(); + downloads.value.push(createMockDownload(secondId, "app-2")); + + downloads.value = []; + + expect(getNextDownloadId()).toBe(secondId + 1); + }); }); }); diff --git a/src/__tests__/unit/processInstall.test.ts b/src/__tests__/unit/processInstall.test.ts index 60a5b24f..de2b85c2 100644 --- a/src/__tests__/unit/processInstall.test.ts +++ b/src/__tests__/unit/processInstall.test.ts @@ -33,4 +33,78 @@ describe("processInstall queue forwarding", () => { expect(send).toHaveBeenCalledWith("queue-install", payload); }); + + it("allocates install ids after existing update tasks", async () => { + const handlers = new Map void>(); + const send = vi.fn(); + + vi.doMock("axios", () => ({ + default: { + create: vi.fn(() => ({ + post: vi.fn(() => Promise.resolve({ data: { ok: true } })), + })), + }, + })); + + Object.assign(window.ipcRenderer, { + on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => { + handlers.set(channel, handler); + }), + send, + invoke: vi.fn(), + }); + + window.apm_store.arch = "amd64"; + + const { downloads } = await import("@/global/downloadStatus"); + downloads.value = [ + { + id: 4, + name: "Spark Weather", + pkgname: "spark-weather", + version: "2.0.0", + icon: "https://example.com/icon.png", + origin: "spark", + status: "downloading", + progress: 0, + downloadedSize: 0, + totalSize: 1024, + speed: 0, + timeRemaining: 0, + startTime: Date.now(), + logs: [], + source: "Update Center", + retry: false, + upgradeOnly: true, + }, + ]; + + const { handleInstall } = await import("@/modules/processInstall"); + + await handleInstall({ + name: "Spark Notes", + pkgname: "spark-notes", + version: "1.0.0", + filename: "spark-notes_1.0.0_amd64.deb", + torrent_address: "spark-notes_1.0.0_amd64.deb.torrent", + author: "Tester", + contributor: "Tester", + website: "https://example.com", + update: "2026-04-13", + size: "10MB", + more: "Test app", + tags: "test", + img_urls: [], + icons: "https://example.com/icon.png", + category: "office", + origin: "spark", + currentStatus: "not-installed", + }); + + expect(downloads.value.map((download) => download.id)).toEqual([4, 5]); + expect(send).toHaveBeenCalledWith( + "queue-install", + expect.stringContaining('"id":5'), + ); + }); }); 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..02c0d6be --- /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: 42 }]); + + expect(send).toHaveBeenCalledWith( + "queue-install", + JSON.stringify({ + id: 42, + 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..26e8753d 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("assigns update-center download ids from a separate range", 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/__tests__/unit/update-center/task-runner.test.ts b/src/__tests__/unit/update-center/task-runner.test.ts index daaa783d..9cf9b00c 100644 --- a/src/__tests__/unit/update-center/task-runner.test.ts +++ b/src/__tests__/unit/update-center/task-runner.test.ts @@ -191,7 +191,8 @@ describe("update-center task runner", () => { { id: task.id, status: "failed", - error: "Update task for spark-player requires download metadata (URL and filename)", + error: + "Update task for spark-player requires download metadata (URL and filename)", }, ], }); diff --git a/src/components/update-center/UpdateCenterToolbar.vue b/src/components/update-center/UpdateCenterToolbar.vue index edae6529..7db4becc 100644 --- a/src/components/update-center/UpdateCenterToolbar.vue +++ b/src/components/update-center/UpdateCenterToolbar.vue @@ -49,7 +49,9 @@ :checked="allSelected" @change="$emit('toggle-select-all')" /> - 全选 + 全选 已选 {{ selectedCount }} 项 @@ -93,7 +95,8 @@ watch( [() => props.someSelected, () => props.allSelected], () => { if (selectAllRef.value) { - selectAllRef.value.indeterminate = props.someSelected && !props.allSelected; + selectAllRef.value.indeterminate = + props.someSelected && !props.allSelected; } }, { flush: "post" }, 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 => {