mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-30 03:10:16 +08:00
修复更新中心发送的下载项和普通下载故障覆盖的问题
This commit is contained in:
@@ -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<string, InstalledSourceState>,
|
||||||
|
): 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
|
||||||
|
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
该应用将从传统 aptss 管理迁移到 APM 管理。迁移过程会先卸载现有 aptss 版本,再安装 APM 版本;迁移完成后,后续更新将由 APM 管理。
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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.
|
||||||
@@ -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 <pkg>`
|
||||||
|
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 <pkg>`
|
||||||
|
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 <pkg>`。
|
||||||
|
- 卸载失败时不会继续安装 `apm`。
|
||||||
|
- 卸载成功后继续执行 `apm` 安装流程。
|
||||||
|
- 前端测试:
|
||||||
|
- 迁移弹窗文案与触发条件正确。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不实现迁移失败后的自动回滚。
|
||||||
|
- 不修改普通 `aptss` 或普通 `apm` 更新的现有安装流程。
|
||||||
|
- 不改变“双安装”场景下两条记录并存的行为。
|
||||||
@@ -244,7 +244,7 @@ export const installPackage = async ({
|
|||||||
filePath,
|
filePath,
|
||||||
"--delete-after-install",
|
"--delete-after-install",
|
||||||
"--no-create-desktop-entry",
|
"--no-create-desktop-entry",
|
||||||
"--native"
|
"--native",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// APM
|
// APM
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
createUpdateCenterService,
|
createUpdateCenterService,
|
||||||
type UpdateCenterIgnorePayload,
|
type UpdateCenterIgnorePayload,
|
||||||
type UpdateCenterService,
|
type UpdateCenterService,
|
||||||
|
type UpdateCenterStartTask,
|
||||||
} from "./service";
|
} from "./service";
|
||||||
import type { UpdateCenterItem } from "./types";
|
import type { UpdateCenterItem } from "./types";
|
||||||
|
|
||||||
@@ -435,8 +436,8 @@ export const registerUpdateCenterIpc = (
|
|||||||
"update-center-unignore",
|
"update-center-unignore",
|
||||||
(_event, payload: UpdateCenterIgnorePayload) => service.unignore(payload),
|
(_event, payload: UpdateCenterIgnorePayload) => service.unignore(payload),
|
||||||
);
|
);
|
||||||
ipc.handle("update-center-start", (_event, taskKeys: string[]) =>
|
ipc.handle("update-center-start", (_event, tasks: UpdateCenterStartTask[]) =>
|
||||||
service.start(taskKeys),
|
service.start(tasks),
|
||||||
);
|
);
|
||||||
ipc.handle("update-center-cancel", (_event, taskKey: string) =>
|
ipc.handle("update-center-cancel", (_event, taskKey: string) =>
|
||||||
service.cancel(taskKey),
|
service.cancel(taskKey),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserWindow, ipcMain } from "electron";
|
import { BrowserWindow } from "electron";
|
||||||
import {
|
import {
|
||||||
LEGACY_IGNORE_CONFIG_PATH,
|
LEGACY_IGNORE_CONFIG_PATH,
|
||||||
applyIgnoredEntries,
|
applyIgnoredEntries,
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from "./ignore-config";
|
} from "./ignore-config";
|
||||||
import {
|
import {
|
||||||
createUpdateCenterQueue,
|
createUpdateCenterQueue,
|
||||||
type UpdateCenterQueue,
|
|
||||||
type UpdateCenterQueueSnapshot,
|
type UpdateCenterQueueSnapshot,
|
||||||
} from "./queue";
|
} from "./queue";
|
||||||
import type { UpdateCenterItem, UpdateSource } from "./types";
|
import type { UpdateCenterItem, UpdateSource } from "./types";
|
||||||
@@ -62,12 +61,17 @@ export interface UpdateCenterIgnorePayload {
|
|||||||
newVersion: string;
|
newVersion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateCenterStartTask {
|
||||||
|
taskKey: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateCenterService {
|
export interface UpdateCenterService {
|
||||||
open: () => Promise<UpdateCenterServiceState>;
|
open: () => Promise<UpdateCenterServiceState>;
|
||||||
refresh: () => Promise<UpdateCenterServiceState>;
|
refresh: () => Promise<UpdateCenterServiceState>;
|
||||||
ignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
|
ignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
|
||||||
unignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
|
unignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
|
||||||
start: (taskKeys: string[]) => Promise<void>;
|
start: (tasks: UpdateCenterStartTask[]) => Promise<void>;
|
||||||
cancel: (taskKey: string) => Promise<void>;
|
cancel: (taskKey: string) => Promise<void>;
|
||||||
getState: () => UpdateCenterServiceState;
|
getState: () => UpdateCenterServiceState;
|
||||||
subscribe: (
|
subscribe: (
|
||||||
@@ -138,8 +142,6 @@ export const createUpdateCenterService = (
|
|||||||
((entries: ReadonlySet<string>) =>
|
((entries: ReadonlySet<string>) =>
|
||||||
saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries));
|
saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries));
|
||||||
|
|
||||||
let nextUpdateTaskId = 1;
|
|
||||||
|
|
||||||
const applyWarning = (message: string): void => {
|
const applyWarning = (message: string): void => {
|
||||||
queue.finishRefresh([message]);
|
queue.finishRefresh([message]);
|
||||||
};
|
};
|
||||||
@@ -188,10 +190,11 @@ export const createUpdateCenterService = (
|
|||||||
await saveIgnored(entries);
|
await saveIgnored(entries);
|
||||||
await refresh();
|
await refresh();
|
||||||
},
|
},
|
||||||
async start(taskKeys) {
|
async start(tasks) {
|
||||||
const snapshot = queue.getSnapshot();
|
const snapshot = queue.getSnapshot();
|
||||||
|
const taskIdByKey = new Map(tasks.map((task) => [task.taskKey, task.id]));
|
||||||
const selectedItems = snapshot.items.filter(
|
const selectedItems = snapshot.items.filter(
|
||||||
(item) => taskKeys.includes(getTaskKey(item)) && !item.ignored,
|
(item) => taskIdByKey.has(getTaskKey(item)) && !item.ignored,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedItems.length === 0) {
|
if (selectedItems.length === 0) {
|
||||||
@@ -211,7 +214,10 @@ export const createUpdateCenterService = (
|
|||||||
let currentItems = snapshot.items;
|
let currentItems = snapshot.items;
|
||||||
|
|
||||||
for (const item of selectedItems) {
|
for (const item of selectedItems) {
|
||||||
const updateTaskId = nextUpdateTaskId++;
|
const updateTaskId = taskIdByKey.get(getTaskKey(item));
|
||||||
|
if (!updateTaskId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 构建 metalink URL
|
// 构建 metalink URL
|
||||||
const metalinkUrl = item.downloadUrl
|
const metalinkUrl = item.downloadUrl
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ type IpcRendererFacade = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
|
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
|
||||||
|
type UpdateCenterStartTask = {
|
||||||
|
taskKey: string;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
const updateCenterStateListeners = new Map<
|
const updateCenterStateListeners = new Map<
|
||||||
UpdateCenterStateListener,
|
UpdateCenterStateListener,
|
||||||
@@ -98,8 +102,8 @@ contextBridge.exposeInMainWorld("updateCenter", {
|
|||||||
packageName: string;
|
packageName: string;
|
||||||
newVersion: string;
|
newVersion: string;
|
||||||
}): Promise<void> => ipcRenderer.invoke("update-center-unignore", payload),
|
}): Promise<void> => ipcRenderer.invoke("update-center-unignore", payload),
|
||||||
start: (taskKeys: string[]): Promise<void> =>
|
start: (tasks: UpdateCenterStartTask[]): Promise<void> =>
|
||||||
ipcRenderer.invoke("update-center-start", taskKeys),
|
ipcRenderer.invoke("update-center-start", tasks),
|
||||||
cancel: (taskKey: string): Promise<void> =>
|
cancel: (taskKey: string): Promise<void> =>
|
||||||
ipcRenderer.invoke("update-center-cancel", taskKey),
|
ipcRenderer.invoke("update-center-cancel", taskKey),
|
||||||
getState: (): Promise<UpdateCenterSnapshot> =>
|
getState: (): Promise<UpdateCenterSnapshot> =>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
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";
|
import type { DownloadItem } from "@/global/typedefinition";
|
||||||
|
|
||||||
describe("downloadStatus", () => {
|
describe("downloadStatus", () => {
|
||||||
@@ -98,5 +102,16 @@ describe("downloadStatus", () => {
|
|||||||
"app-3",
|
"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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,4 +33,78 @@ describe("processInstall queue forwarding", () => {
|
|||||||
|
|
||||||
expect(send).toHaveBeenCalledWith("queue-install", payload);
|
expect(send).toHaveBeenCalledWith("queue-install", payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allocates install ids after existing update tasks", async () => {
|
||||||
|
const handlers = new Map<string, (...args: unknown[]) => 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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,7 +96,12 @@ describe("updateCenter store", () => {
|
|||||||
store.toggleSelection("apm:spark-clock");
|
store.toggleSelection("apm:spark-clock");
|
||||||
await store.startSelected();
|
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 () => {
|
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", () => {
|
it("blocks close requests while the snapshot reports running tasks", () => {
|
||||||
const store = createUpdateCenterStore();
|
const store = createUpdateCenterStore();
|
||||||
store.isOpen.value = true;
|
store.isOpen.value = true;
|
||||||
|
|||||||
@@ -191,7 +191,8 @@ describe("update-center task runner", () => {
|
|||||||
{
|
{
|
||||||
id: task.id,
|
id: task.id,
|
||||||
status: "failed",
|
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)",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,9 @@
|
|||||||
:checked="allSelected"
|
:checked="allSelected"
|
||||||
@change="$emit('toggle-select-all')"
|
@change="$emit('toggle-select-all')"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">全选</span>
|
<span class="text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||||
|
>全选</span
|
||||||
|
>
|
||||||
</label>
|
</label>
|
||||||
<span class="text-sm text-slate-400 dark:text-slate-500">
|
<span class="text-sm text-slate-400 dark:text-slate-500">
|
||||||
已选 {{ selectedCount }} 项
|
已选 {{ selectedCount }} 项
|
||||||
@@ -93,7 +95,8 @@ watch(
|
|||||||
[() => props.someSelected, () => props.allSelected],
|
[() => props.someSelected, () => props.allSelected],
|
||||||
() => {
|
() => {
|
||||||
if (selectAllRef.value) {
|
if (selectAllRef.value) {
|
||||||
selectAllRef.value.indeterminate = props.someSelected && !props.allSelected;
|
selectAllRef.value.indeterminate =
|
||||||
|
props.someSelected && !props.allSelected;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ flush: "post" },
|
{ flush: "post" },
|
||||||
|
|||||||
@@ -3,6 +3,34 @@ import type { DownloadItem, DownloadItemStatus } from "./typedefinition";
|
|||||||
|
|
||||||
export const downloads = ref<DownloadItem[]>([]);
|
export const downloads = ref<DownloadItem[]>([]);
|
||||||
|
|
||||||
|
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) {
|
export function removeDownloadItem(pkgname: string) {
|
||||||
const list = downloads.value;
|
const list = downloads.value;
|
||||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||||
|
|||||||
@@ -165,6 +165,11 @@ export interface UpdateCenterTaskState {
|
|||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateCenterStartTask {
|
||||||
|
taskKey: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateCenterSnapshot {
|
export interface UpdateCenterSnapshot {
|
||||||
items: UpdateCenterItem[];
|
items: UpdateCenterItem[];
|
||||||
tasks: UpdateCenterTaskState[];
|
tasks: UpdateCenterTaskState[];
|
||||||
@@ -183,7 +188,7 @@ export interface UpdateCenterBridge {
|
|||||||
packageName: string;
|
packageName: string;
|
||||||
newVersion: string;
|
newVersion: string;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
start: (taskKeys: string[]) => Promise<void>;
|
start: (tasks: UpdateCenterStartTask[]) => Promise<void>;
|
||||||
cancel: (taskKey: string) => Promise<void>;
|
cancel: (taskKey: string) => Promise<void>;
|
||||||
getState: () => Promise<UpdateCenterSnapshot>;
|
getState: () => Promise<UpdateCenterSnapshot>;
|
||||||
onState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void;
|
onState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
currentAppApmInstalled,
|
currentAppApmInstalled,
|
||||||
} from "../global/storeConfig";
|
} from "../global/storeConfig";
|
||||||
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||||
import { downloads } from "../global/downloadStatus";
|
import { downloads, getNextDownloadId } from "../global/downloadStatus";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InstallLog,
|
InstallLog,
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
} from "../global/typedefinition";
|
} from "../global/typedefinition";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
let downloadIdCounter = 0;
|
|
||||||
const logger = pino({ name: "processInstall.ts" });
|
const logger = pino({ name: "processInstall.ts" });
|
||||||
|
|
||||||
export const handleInstall = async (appObj?: App) => {
|
export const handleInstall = async (appObj?: App) => {
|
||||||
@@ -51,14 +50,14 @@ export const handleInstall = async (appObj?: App) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadIdCounter += 1;
|
|
||||||
// 创建下载任务
|
// 创建下载任务
|
||||||
const arch = window.apm_store.arch || "amd64";
|
const arch = window.apm_store.arch || "amd64";
|
||||||
const finalArch =
|
const finalArch =
|
||||||
targetApp.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
targetApp.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
const downloadId = getNextDownloadId();
|
||||||
|
|
||||||
const download: DownloadItem = {
|
const download: DownloadItem = {
|
||||||
id: downloadIdCounter,
|
id: downloadId,
|
||||||
name: targetApp.name,
|
name: targetApp.name,
|
||||||
pkgname: targetApp.pkgname,
|
pkgname: targetApp.pkgname,
|
||||||
version: targetApp.version,
|
version: targetApp.version,
|
||||||
@@ -140,12 +139,12 @@ export const handleUpgrade = async (app: App) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadIdCounter += 1;
|
|
||||||
const arch = window.apm_store.arch || "amd64";
|
const arch = window.apm_store.arch || "amd64";
|
||||||
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||||
|
const downloadId = getNextDownloadId();
|
||||||
|
|
||||||
const download: DownloadItem = {
|
const download: DownloadItem = {
|
||||||
id: downloadIdCounter,
|
id: downloadId,
|
||||||
name: app.name,
|
name: app.name,
|
||||||
pkgname: app.pkgname,
|
pkgname: app.pkgname,
|
||||||
version: app.version,
|
version: app.version,
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import type {
|
|||||||
UpdateCenterItem,
|
UpdateCenterItem,
|
||||||
UpdateCenterSnapshot,
|
UpdateCenterSnapshot,
|
||||||
DownloadItem,
|
DownloadItem,
|
||||||
|
UpdateCenterStartTask,
|
||||||
} from "@/global/typedefinition";
|
} from "@/global/typedefinition";
|
||||||
import { downloads } from "@/global/downloadStatus";
|
import { downloads, getNextUpdateDownloadId } from "@/global/downloadStatus";
|
||||||
import { APM_STORE_BASE_URL } from "@/global/storeConfig";
|
import { APM_STORE_BASE_URL } from "@/global/storeConfig";
|
||||||
|
|
||||||
const EMPTY_SNAPSHOT: UpdateCenterSnapshot = {
|
const EMPTY_SNAPSHOT: UpdateCenterSnapshot = {
|
||||||
@@ -88,12 +89,18 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
|
|
||||||
const allSelected = computed(() => {
|
const allSelected = computed(() => {
|
||||||
const selectable = selectableItems.value;
|
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 someSelected = computed(() => {
|
||||||
const selectable = selectableItems.value;
|
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 => {
|
const handleState = (nextSnapshot: UpdateCenterSnapshot): void => {
|
||||||
@@ -173,18 +180,13 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
|
|
||||||
const startSelected = async (): Promise<void> => {
|
const startSelected = async (): Promise<void> => {
|
||||||
const selectedItems = getSelectedItems();
|
const selectedItems = getSelectedItems();
|
||||||
const taskKeys = selectedItems.map((item) => item.taskKey);
|
if (selectedItems.length === 0) {
|
||||||
|
|
||||||
if (taskKeys.length === 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在前端创建下载项,这样用户能在下载列表中看到更新任务
|
// 在前端创建下载项,这样用户能在下载列表中看到更新任务
|
||||||
const arch = window.apm_store.arch || "amd64";
|
const arch = window.apm_store.arch || "amd64";
|
||||||
let downloadIdCounter =
|
const startTasks: UpdateCenterStartTask[] = [];
|
||||||
downloads.value.length > 0
|
|
||||||
? Math.max(...downloads.value.map((d) => d.id)) + 1
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
selectedItems.forEach((item) => {
|
selectedItems.forEach((item) => {
|
||||||
// 检查任务是否已存在
|
// 检查任务是否已存在
|
||||||
@@ -200,8 +202,9 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
const icon =
|
const icon =
|
||||||
item.remoteIcon ||
|
item.remoteIcon ||
|
||||||
`${APM_STORE_BASE_URL}/${finalArch}/unknown/${item.packageName}/icon.png`;
|
`${APM_STORE_BASE_URL}/${finalArch}/unknown/${item.packageName}/icon.png`;
|
||||||
|
const downloadId = getNextUpdateDownloadId();
|
||||||
const download: DownloadItem = {
|
const download: DownloadItem = {
|
||||||
id: downloadIdCounter++,
|
id: downloadId,
|
||||||
name: item.displayName,
|
name: item.displayName,
|
||||||
pkgname: item.packageName,
|
pkgname: item.packageName,
|
||||||
version: item.newVersion,
|
version: item.newVersion,
|
||||||
@@ -224,10 +227,18 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
downloads.value.push(download);
|
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 => {
|
const requestClose = (): void => {
|
||||||
|
|||||||
Reference in New Issue
Block a user