mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-30 03:10:16 +08:00
docs(update-center): add implementation notes
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -38,4 +38,5 @@ pnpm-lock.yaml
|
|||||||
yarn.lock
|
yarn.lock
|
||||||
.lock
|
.lock
|
||||||
|
|
||||||
test-results.json
|
test-results.json
|
||||||
|
.worktrees/
|
||||||
|
|||||||
801
docs/superpowers/plans/2026-04-10-update-center-icon-fallback.md
Normal file
801
docs/superpowers/plans/2026-04-10-update-center-icon-fallback.md
Normal file
@@ -0,0 +1,801 @@
|
|||||||
|
# Update Center Icon Fallback 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:** Change Electron update-center icons to load in the order `localIcon -> remoteIcon -> placeholder`, so a successful local icon never triggers remote/default loading, but failed local loads still fall through to the remote icon.
|
||||||
|
|
||||||
|
**Architecture:** Split the current single `icon` field into two explicit sources resolved in the main process: `localIcon` and `remoteIcon`. Keep URL/path resolution in `electron/main/backend/update-center/icons.ts`, pass both fields through the service snapshot, and let `UpdateCenterItem.vue` own the runtime fallback state when `img` emits `error`.
|
||||||
|
|
||||||
|
**Tech Stack:** Electron main process, Node.js `fs`/`path`, Vue 3 `<script setup>`, TypeScript strict mode, Vitest, Testing Library Vue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Modify: `electron/main/backend/update-center/types.ts` - replace the single update-center icon field with `localIcon` and `remoteIcon`.
|
||||||
|
- Modify: `electron/main/backend/update-center/icons.ts` - keep local/remote resolution helpers and return both candidates via `resolveUpdateItemIcons()`.
|
||||||
|
- Modify: `electron/main/backend/update-center/index.ts` - enrich loaded update items with the two icon fields instead of one final `icon`.
|
||||||
|
- Modify: `electron/main/backend/update-center/service.ts` - expose `localIcon` and `remoteIcon` to renderer item/task snapshots.
|
||||||
|
- Modify: `src/global/typedefinition.ts` - update renderer-facing update-center item/task types.
|
||||||
|
- Modify: `src/components/update-center/UpdateCenterItem.vue` - render the current icon candidate and advance from local to remote to placeholder on load failures.
|
||||||
|
- Modify: `src/__tests__/unit/update-center/icons.test.ts` - verify icon helper output is now `{ localIcon?, remoteIcon? }`.
|
||||||
|
- Modify: `src/__tests__/unit/update-center/load-items.test.ts` - verify loaded items receive `remoteIcon` instead of the old `icon` field.
|
||||||
|
- Modify: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts` - verify service task snapshots preserve both icon fields.
|
||||||
|
- Modify: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts` - verify the renderer fallback order.
|
||||||
|
|
||||||
|
### Task 1: Split Backend Icon Resolution Into Local And Remote Sources
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `electron/main/backend/update-center/types.ts`
|
||||||
|
- Modify: `electron/main/backend/update-center/icons.ts`
|
||||||
|
- Test: `src/__tests__/unit/update-center/icons.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Replace the single-icon assertions in `src/__tests__/unit/update-center/icons.test.ts` with these four tests:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("returns both localIcon and remoteIcon when an aptss desktop icon resolves", async () => {
|
||||||
|
const pkgname = "spark-weather";
|
||||||
|
const applicationsDirectory = "/usr/share/applications";
|
||||||
|
const desktopPath = `${applicationsDirectory}/weather-launcher.desktop`;
|
||||||
|
const iconPath = `/usr/share/pixmaps/${pkgname}.png`;
|
||||||
|
const { resolveUpdateItemIcons } = await loadIconsModule({
|
||||||
|
directories: {
|
||||||
|
[applicationsDirectory]: ["weather-launcher.desktop"],
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
[desktopPath]: `[Desktop Entry]\nName=Spark Weather\nIcon=${iconPath}\n`,
|
||||||
|
[iconPath]: "png",
|
||||||
|
},
|
||||||
|
packageFiles: {
|
||||||
|
[pkgname]: [desktopPath],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveUpdateItemIcons({
|
||||||
|
pkgname,
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
category: "tools",
|
||||||
|
arch: "amd64",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
localIcon: iconPath,
|
||||||
|
remoteIcon:
|
||||||
|
"https://erotica.spark-app.store/amd64-store/tools/spark-weather/icon.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only remoteIcon when no local icon resolves", async () => {
|
||||||
|
const { resolveUpdateItemIcons } = await loadIconsModule({});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveUpdateItemIcons({
|
||||||
|
pkgname: "spark-clock",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
category: "utility",
|
||||||
|
arch: "amd64",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
remoteIcon:
|
||||||
|
"https://erotica.spark-app.store/amd64-apm/utility/spark-clock/icon.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only localIcon when a remote fallback URL cannot be built", async () => {
|
||||||
|
const pkgname = "spark-reader";
|
||||||
|
const applicationsDirectory = "/usr/share/applications";
|
||||||
|
const desktopPath = `${applicationsDirectory}/reader-launcher.desktop`;
|
||||||
|
const iconPath = `/usr/share/pixmaps/${pkgname}.png`;
|
||||||
|
const { resolveUpdateItemIcons } = await loadIconsModule({
|
||||||
|
directories: {
|
||||||
|
[applicationsDirectory]: ["reader-launcher.desktop"],
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
[desktopPath]: `[Desktop Entry]\nName=Spark Reader\nIcon=${iconPath}\n`,
|
||||||
|
[iconPath]: "png",
|
||||||
|
},
|
||||||
|
packageFiles: {
|
||||||
|
[pkgname]: [desktopPath],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveUpdateItemIcons({
|
||||||
|
pkgname,
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
localIcon: iconPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty object when neither local nor remote icons are available", async () => {
|
||||||
|
const { resolveUpdateItemIcons } = await loadIconsModule({});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveUpdateItemIcons({
|
||||||
|
pkgname: "spark-empty",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
}),
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because `resolveUpdateItemIcon()` still returns a string and `resolveUpdateItemIcons()` does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Update `electron/main/backend/update-center/types.ts` so the interface defines the two source fields instead of `icon`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface UpdateCenterItem {
|
||||||
|
pkgname: string;
|
||||||
|
source: UpdateSource;
|
||||||
|
currentVersion: string;
|
||||||
|
nextVersion: string;
|
||||||
|
arch?: string;
|
||||||
|
category?: string;
|
||||||
|
localIcon?: string;
|
||||||
|
remoteIcon?: string;
|
||||||
|
ignored?: boolean;
|
||||||
|
downloadUrl?: string;
|
||||||
|
fileName?: string;
|
||||||
|
size?: number;
|
||||||
|
sha512?: string;
|
||||||
|
isMigration?: boolean;
|
||||||
|
migrationSource?: UpdateSource;
|
||||||
|
migrationTarget?: UpdateSource;
|
||||||
|
aptssVersion?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the old single-result helper at the end of `electron/main/backend/update-center/icons.ts` with this code:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface UpdateItemIcons {
|
||||||
|
localIcon?: string;
|
||||||
|
remoteIcon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveUpdateItemIcons = (
|
||||||
|
item: UpdateCenterItem,
|
||||||
|
): UpdateItemIcons => {
|
||||||
|
const localIcon =
|
||||||
|
item.source === "aptss"
|
||||||
|
? resolveDesktopIcon(item.pkgname)
|
||||||
|
: resolveApmIcon(item.pkgname);
|
||||||
|
const remoteIcon =
|
||||||
|
buildRemoteFallbackIconUrl({
|
||||||
|
pkgname: item.pkgname,
|
||||||
|
source: item.source,
|
||||||
|
arch: item.arch,
|
||||||
|
category: item.category,
|
||||||
|
}) || undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(localIcon ? { localIcon } : {}),
|
||||||
|
...(remoteIcon ? { remoteIcon } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `resolveDesktopIcon()`, `resolveApmIcon()`, and `buildRemoteFallbackIconUrl()` unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS with the updated icon helper tests green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add electron/main/backend/update-center/types.ts electron/main/backend/update-center/icons.ts src/__tests__/unit/update-center/icons.test.ts
|
||||||
|
git commit -m "fix(update-center): split local and remote icon sources"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Propagate Icon Sources Through Loaded Items And Service Snapshots
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `electron/main/backend/update-center/index.ts`
|
||||||
|
- Modify: `electron/main/backend/update-center/service.ts`
|
||||||
|
- Modify: `src/global/typedefinition.ts`
|
||||||
|
- Test: `src/__tests__/unit/update-center/load-items.test.ts`
|
||||||
|
- Test: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Update the expected item snapshots in `src/__tests__/unit/update-center/load-items.test.ts` from `icon` to `remoteIcon`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(result.items).toContainEqual({
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1.5.0",
|
||||||
|
nextVersion: "3.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
category: "tools",
|
||||||
|
remoteIcon:
|
||||||
|
"https://erotica.spark-app.store/amd64-apm/tools/spark-weather/icon.png",
|
||||||
|
downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb",
|
||||||
|
fileName: "spark-weather_3.0.0_amd64.deb",
|
||||||
|
size: 123456,
|
||||||
|
sha512: "deadbeef",
|
||||||
|
isMigration: true,
|
||||||
|
migrationSource: "aptss",
|
||||||
|
migrationTarget: "apm",
|
||||||
|
aptssVersion: "2.0.0",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(result.items).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-notes",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
category: "office",
|
||||||
|
remoteIcon:
|
||||||
|
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(secondResult.items).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-notes",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
category: "office",
|
||||||
|
remoteIcon:
|
||||||
|
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(result.items).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-notes",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
category: "office",
|
||||||
|
remoteIcon:
|
||||||
|
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the icon-preservation test in `src/__tests__/unit/update-center/registerUpdateCenter.test.ts` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("service task snapshots keep localIcon and remoteIcon for queued work", async () => {
|
||||||
|
const service = createUpdateCenterService({
|
||||||
|
loadItems: async () => [
|
||||||
|
{
|
||||||
|
...createItem(),
|
||||||
|
localIcon: "/icons/weather.png",
|
||||||
|
remoteIcon: "https://example.com/weather.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createTaskRunner: (queue: UpdateCenterQueue) => ({
|
||||||
|
cancelActiveTask: vi.fn(),
|
||||||
|
runNextTask: async () => {
|
||||||
|
const task = queue.getNextQueuedTask();
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.markActiveTask(task.id, "installing");
|
||||||
|
queue.finishTask(task.id, "completed");
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.refresh();
|
||||||
|
await service.start(["aptss:spark-weather"]);
|
||||||
|
|
||||||
|
expect(service.getState().tasks).toMatchObject([
|
||||||
|
{
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
localIcon: "/icons/weather.png",
|
||||||
|
remoteIcon: "https://example.com/weather.png",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because the loader and service snapshots still publish `icon` instead of `localIcon` / `remoteIcon`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Update the icon enrichment function in `electron/main/backend/update-center/index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { resolveUpdateItemIcons } from "./icons";
|
||||||
|
|
||||||
|
const enrichItemIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => {
|
||||||
|
return items.map((item) => {
|
||||||
|
const { localIcon, remoteIcon } = resolveUpdateItemIcons(item);
|
||||||
|
if (!localIcon && !remoteIcon) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...(localIcon ? { localIcon } : {}),
|
||||||
|
...(remoteIcon ? { remoteIcon } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the renderer-facing item/task types and `toState()` mapping in `electron/main/backend/update-center/service.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface UpdateCenterServiceItem {
|
||||||
|
taskKey: string;
|
||||||
|
packageName: string;
|
||||||
|
displayName: string;
|
||||||
|
currentVersion: string;
|
||||||
|
newVersion: string;
|
||||||
|
source: UpdateSource;
|
||||||
|
localIcon?: string;
|
||||||
|
remoteIcon?: string;
|
||||||
|
ignored?: boolean;
|
||||||
|
downloadUrl?: string;
|
||||||
|
fileName?: string;
|
||||||
|
size?: number;
|
||||||
|
sha512?: string;
|
||||||
|
isMigration?: boolean;
|
||||||
|
migrationSource?: UpdateSource;
|
||||||
|
migrationTarget?: UpdateSource;
|
||||||
|
aptssVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCenterServiceTask {
|
||||||
|
taskKey: string;
|
||||||
|
packageName: string;
|
||||||
|
source: UpdateSource;
|
||||||
|
localIcon?: string;
|
||||||
|
remoteIcon?: string;
|
||||||
|
status: UpdateCenterQueueSnapshot["tasks"][number]["status"];
|
||||||
|
progress: number;
|
||||||
|
logs: UpdateCenterQueueSnapshot["tasks"][number]["logs"];
|
||||||
|
errorMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toState = (
|
||||||
|
snapshot: UpdateCenterQueueSnapshot,
|
||||||
|
): UpdateCenterServiceState => ({
|
||||||
|
items: snapshot.items.map((item) => ({
|
||||||
|
taskKey: getTaskKey(item),
|
||||||
|
packageName: item.pkgname,
|
||||||
|
displayName: item.pkgname,
|
||||||
|
currentVersion: item.currentVersion,
|
||||||
|
newVersion: item.nextVersion,
|
||||||
|
source: item.source,
|
||||||
|
localIcon: item.localIcon,
|
||||||
|
remoteIcon: item.remoteIcon,
|
||||||
|
ignored: item.ignored,
|
||||||
|
downloadUrl: item.downloadUrl,
|
||||||
|
fileName: item.fileName,
|
||||||
|
size: item.size,
|
||||||
|
sha512: item.sha512,
|
||||||
|
isMigration: item.isMigration,
|
||||||
|
migrationSource: item.migrationSource,
|
||||||
|
migrationTarget: item.migrationTarget,
|
||||||
|
aptssVersion: item.aptssVersion,
|
||||||
|
})),
|
||||||
|
tasks: snapshot.tasks.map((task) => ({
|
||||||
|
taskKey: getTaskKey(task.item),
|
||||||
|
packageName: task.pkgname,
|
||||||
|
source: task.item.source,
|
||||||
|
localIcon: task.item.localIcon,
|
||||||
|
remoteIcon: task.item.remoteIcon,
|
||||||
|
status: task.status,
|
||||||
|
progress: task.progress,
|
||||||
|
logs: task.logs.map((log) => ({ ...log })),
|
||||||
|
errorMessage: task.error ?? "",
|
||||||
|
})),
|
||||||
|
warnings: [...snapshot.warnings],
|
||||||
|
hasRunningTasks: snapshot.hasRunningTasks,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the update-center renderer types in `src/global/typedefinition.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface UpdateCenterItem {
|
||||||
|
taskKey: string;
|
||||||
|
packageName: string;
|
||||||
|
displayName: string;
|
||||||
|
currentVersion: string;
|
||||||
|
newVersion: string;
|
||||||
|
source: UpdateSource;
|
||||||
|
localIcon?: string;
|
||||||
|
remoteIcon?: string;
|
||||||
|
ignored?: boolean;
|
||||||
|
downloadUrl?: string;
|
||||||
|
fileName?: string;
|
||||||
|
size?: number;
|
||||||
|
sha512?: string;
|
||||||
|
isMigration?: boolean;
|
||||||
|
migrationSource?: UpdateSource;
|
||||||
|
migrationTarget?: UpdateSource;
|
||||||
|
aptssVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCenterTaskState {
|
||||||
|
taskKey: string;
|
||||||
|
packageName: string;
|
||||||
|
source: UpdateSource;
|
||||||
|
localIcon?: string;
|
||||||
|
remoteIcon?: string;
|
||||||
|
status: UpdateCenterTaskStatus;
|
||||||
|
progress: number;
|
||||||
|
logs: Array<{ time: number; message: string }>;
|
||||||
|
errorMessage: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS with the loader and service tests green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add electron/main/backend/update-center/index.ts electron/main/backend/update-center/service.ts src/global/typedefinition.ts src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts
|
||||||
|
git commit -m "refactor(update-center): propagate icon fallback fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Implement Renderer Fallback Order In UpdateCenterItem.vue
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/components/update-center/UpdateCenterItem.vue`
|
||||||
|
- Test: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Replace the contents of `src/__tests__/unit/update-center/UpdateCenterItem.test.ts` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import UpdateCenterItem from "@/components/update-center/UpdateCenterItem.vue";
|
||||||
|
import type {
|
||||||
|
UpdateCenterItem as UpdateCenterItemData,
|
||||||
|
UpdateCenterTaskState,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const createItem = (
|
||||||
|
overrides: Partial<UpdateCenterItemData> = {},
|
||||||
|
): UpdateCenterItemData => ({
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
packageName: "spark-weather",
|
||||||
|
displayName: "Spark Weather",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
source: "aptss",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTask = (
|
||||||
|
overrides: Partial<UpdateCenterTaskState> = {},
|
||||||
|
): UpdateCenterTaskState => ({
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
packageName: "spark-weather",
|
||||||
|
source: "aptss",
|
||||||
|
status: "downloading",
|
||||||
|
progress: 42,
|
||||||
|
logs: [],
|
||||||
|
errorMessage: "",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UpdateCenterItem", () => {
|
||||||
|
it("renders localIcon first when both icon sources exist", () => {
|
||||||
|
render(UpdateCenterItem, {
|
||||||
|
props: {
|
||||||
|
item: createItem({
|
||||||
|
localIcon: "/usr/share/pixmaps/spark-weather.png",
|
||||||
|
remoteIcon: "https://example.com/spark-weather.png",
|
||||||
|
}),
|
||||||
|
task: createTask(),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||||
|
|
||||||
|
expect(icon).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"file:///usr/share/pixmaps/spark-weather.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to remoteIcon when localIcon fails", async () => {
|
||||||
|
render(UpdateCenterItem, {
|
||||||
|
props: {
|
||||||
|
item: createItem({
|
||||||
|
localIcon: "/usr/share/pixmaps/spark-weather.png",
|
||||||
|
remoteIcon: "https://example.com/spark-weather.png",
|
||||||
|
}),
|
||||||
|
task: createTask(),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||||
|
|
||||||
|
await fireEvent.error(icon);
|
||||||
|
|
||||||
|
expect(icon).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"https://example.com/spark-weather.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the placeholder after localIcon and remoteIcon both fail", async () => {
|
||||||
|
render(UpdateCenterItem, {
|
||||||
|
props: {
|
||||||
|
item: createItem({
|
||||||
|
localIcon: "/usr/share/pixmaps/spark-weather.png",
|
||||||
|
remoteIcon: "https://example.com/spark-weather.png",
|
||||||
|
}),
|
||||||
|
task: createTask(),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||||
|
|
||||||
|
await fireEvent.error(icon);
|
||||||
|
await fireEvent.error(icon);
|
||||||
|
|
||||||
|
expect(icon.getAttribute("src")).toContain("data:image/svg+xml");
|
||||||
|
expect(icon.getAttribute("src")).not.toContain(
|
||||||
|
"https://example.com/spark-weather.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts from localIcon when a new item is rendered", async () => {
|
||||||
|
const { rerender } = render(UpdateCenterItem, {
|
||||||
|
props: {
|
||||||
|
item: createItem({
|
||||||
|
localIcon: "/usr/share/pixmaps/spark-weather.png",
|
||||||
|
remoteIcon: "https://example.com/spark-weather.png",
|
||||||
|
}),
|
||||||
|
task: createTask(),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||||
|
|
||||||
|
await fireEvent.error(firstIcon);
|
||||||
|
|
||||||
|
expect(firstIcon).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"https://example.com/spark-weather.png",
|
||||||
|
);
|
||||||
|
|
||||||
|
await rerender({
|
||||||
|
item: createItem({
|
||||||
|
taskKey: "aptss:spark-clock",
|
||||||
|
packageName: "spark-clock",
|
||||||
|
displayName: "Spark Clock",
|
||||||
|
localIcon: "/usr/share/pixmaps/spark-clock.png",
|
||||||
|
remoteIcon: "https://example.com/spark-clock.png",
|
||||||
|
}),
|
||||||
|
task: createTask({
|
||||||
|
taskKey: "aptss:spark-clock",
|
||||||
|
packageName: "spark-clock",
|
||||||
|
}),
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextIcon = screen.getByRole("img", { name: "Spark Clock 图标" });
|
||||||
|
|
||||||
|
expect(nextIcon).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"file:///usr/share/pixmaps/spark-clock.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because the component still reads `item.icon` and goes straight from a single failed image to the placeholder.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Replace the `<script setup>` block in `src/components/update-center/UpdateCenterItem.vue` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
UpdateCenterItem,
|
||||||
|
UpdateCenterTaskState,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: UpdateCenterItem;
|
||||||
|
task?: UpdateCenterTaskState;
|
||||||
|
selected: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const PLACEHOLDER_ICON =
|
||||||
|
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"%3E%3Crect width="48" height="48" rx="12" fill="%23e2e8f0"/%3E%3Cpath d="M17 31h14v2H17zm3-12h8a2 2 0 0 1 2 2v8H18v-8a2 2 0 0 1 2-2" fill="%2394a3b8"/%3E%3C/svg%3E';
|
||||||
|
const currentIconIndex = ref(0);
|
||||||
|
const allCandidatesFailed = ref(false);
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: "toggle-selection"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const normalizeIconSrc = (icon: string): string => {
|
||||||
|
if (/^[a-z]+:\/\//i.test(icon)) {
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
return icon.startsWith("/") ? `file://${icon}` : icon;
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconCandidates = computed(() => {
|
||||||
|
return [props.item.localIcon, props.item.remoteIcon]
|
||||||
|
.filter((icon): icon is string => Boolean(icon && icon.trim().length > 0))
|
||||||
|
.map((icon) => normalizeIconSrc(icon));
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetIconFallback = () => {
|
||||||
|
currentIconIndex.value = 0;
|
||||||
|
allCandidatesFailed.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIconError = () => {
|
||||||
|
if (currentIconIndex.value < iconCandidates.value.length - 1) {
|
||||||
|
currentIconIndex.value += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
allCandidatesFailed.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.item,
|
||||||
|
() => {
|
||||||
|
resetIconFallback();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconSrc = computed(() => {
|
||||||
|
if (allCandidatesFailed.value || iconCandidates.value.length === 0) {
|
||||||
|
return PLACEHOLDER_ICON;
|
||||||
|
}
|
||||||
|
|
||||||
|
return iconCandidates.value[currentIconIndex.value] ?? PLACEHOLDER_ICON;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceLabel = computed(() => {
|
||||||
|
return props.item.source === "apm" ? "APM" : "传统deb";
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
switch (props.task?.status) {
|
||||||
|
case "downloading":
|
||||||
|
return "下载中";
|
||||||
|
case "installing":
|
||||||
|
return "安装中";
|
||||||
|
case "completed":
|
||||||
|
return "已完成";
|
||||||
|
case "failed":
|
||||||
|
return "失败";
|
||||||
|
case "cancelled":
|
||||||
|
return "已取消";
|
||||||
|
default:
|
||||||
|
return "待处理";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const showProgress = computed(() => {
|
||||||
|
return (
|
||||||
|
props.task?.status === "downloading" || props.task?.status === "installing"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressText = computed(() => `${props.task?.progress ?? 0}%`);
|
||||||
|
const progressStyle = computed(() => ({ width: progressText.value }));
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS with all fallback-order component tests green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/update-center/UpdateCenterItem.vue src/__tests__/unit/update-center/UpdateCenterItem.test.ts
|
||||||
|
git commit -m "fix(update-center): cascade icon fallback in renderer"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Verify The Full Change Set
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Verify only: `electron/main/backend/update-center/types.ts`
|
||||||
|
- Verify only: `electron/main/backend/update-center/icons.ts`
|
||||||
|
- Verify only: `electron/main/backend/update-center/index.ts`
|
||||||
|
- Verify only: `electron/main/backend/update-center/service.ts`
|
||||||
|
- Verify only: `src/global/typedefinition.ts`
|
||||||
|
- Verify only: `src/components/update-center/UpdateCenterItem.vue`
|
||||||
|
- Verify only: `src/__tests__/unit/update-center/icons.test.ts`
|
||||||
|
- Verify only: `src/__tests__/unit/update-center/load-items.test.ts`
|
||||||
|
- Verify only: `src/__tests__/unit/update-center/registerUpdateCenter.test.ts`
|
||||||
|
- Verify only: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the focused update-center test suite**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/registerUpdateCenter.test.ts src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS with all four update-center suites green.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the formatter**
|
||||||
|
|
||||||
|
Run: `npm run format`
|
||||||
|
|
||||||
|
Expected: command exits 0 after formatting the touched files.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run lint**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
|
||||||
|
Expected: PASS with no ESLint or Prettier violations.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the production build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
|
||||||
|
Expected: PASS with Vite/Electron build output generated successfully and no TypeScript errors.
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# 更新中心图标逐级回退设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前更新中心的图标解析分成两段:
|
||||||
|
|
||||||
|
1. 主进程 `electron/main/backend/update-center/icons.ts` 会优先解析本地图标,解析不到时再直接返回线上图标 URL。
|
||||||
|
2. 渲染层 `src/components/update-center/UpdateCenterItem.vue` 只接收单个 `icon` 字段,图片加载失败后直接回退到默认占位图。
|
||||||
|
|
||||||
|
这个结构已经满足“本地优先”的静态选择,但不能满足新的行为要求:
|
||||||
|
|
||||||
|
1. 当本地图标成功加载时,不再请求线上图标和默认图标。
|
||||||
|
2. 当本地图标路径虽然存在、但实际加载失败时,继续尝试线上图标。
|
||||||
|
3. 当线上图标也失败时,最后才回退到默认占位图。
|
||||||
|
|
||||||
|
问题根因不是路径优先级判断错误,而是当前前后端只传递了一个最终 `icon` 值,导致前端无法在运行时根据真实加载结果继续尝试下一层来源。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
1. 更新中心图标加载顺序固定为:`localIcon -> remoteIcon -> placeholder`。
|
||||||
|
2. 本地图标加载成功时,不再加载线上图标和默认图标。
|
||||||
|
3. 本地图标加载失败时,自动切换到线上图标。
|
||||||
|
4. 线上图标也失败时,才显示默认占位图。
|
||||||
|
5. 保持当前更新中心列表布局、图标尺寸和已有解析路径规则不变。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
1. 不改动主商店、已安装列表或其他页面的图标逻辑。
|
||||||
|
2. 不增加新的网络探测请求,也不预检远程图标是否可访问。
|
||||||
|
3. 不重构现有本地图标解析算法,只调整数据结构和回退链路。
|
||||||
|
4. 不引入通用的图标来源数组或复杂图标对象。
|
||||||
|
|
||||||
|
## 方案选择
|
||||||
|
|
||||||
|
本次考虑过三种方案:
|
||||||
|
|
||||||
|
1. 后端透传 `localIcon` 和 `remoteIcon` 两个字段,前端顺序尝试。
|
||||||
|
2. 后端透传 `iconCandidates: string[]`,前端按数组顺序尝试。
|
||||||
|
3. 继续只传一个 `icon`,前端根据 `pkgname/category/arch` 自己重新拼线上图标地址。
|
||||||
|
|
||||||
|
最终选择方案 1。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
1. 它刚好对应本次明确的三级回退需求,最小且直接。
|
||||||
|
2. 后端继续掌握图标来源规则,避免前端复制商店 URL 规则。
|
||||||
|
3. 相比数组方案,双字段更易读、更容易在 IPC 类型中维护。
|
||||||
|
4. 前端只负责“加载失败后切换到下一来源”,职责边界清晰。
|
||||||
|
|
||||||
|
## 设计概览
|
||||||
|
|
||||||
|
更新中心改为“主进程解析来源,渲染层控制加载顺序”的结构:
|
||||||
|
|
||||||
|
1. 主进程为每个更新项分别计算 `localIcon` 和 `remoteIcon`。
|
||||||
|
2. 服务层和前端类型透传这两个字段。
|
||||||
|
3. `UpdateCenterItem.vue` 按 `localIcon -> remoteIcon -> placeholder` 的顺序逐级尝试。
|
||||||
|
4. 候选图标一旦成功加载,组件不再切换到后续来源。
|
||||||
|
|
||||||
|
## 数据结构变更
|
||||||
|
|
||||||
|
### 主进程类型
|
||||||
|
|
||||||
|
修改:`electron/main/backend/update-center/types.ts`
|
||||||
|
|
||||||
|
将:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
icon?: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
localIcon?: string;
|
||||||
|
remoteIcon?: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Snapshot
|
||||||
|
|
||||||
|
修改:`electron/main/backend/update-center/service.ts`
|
||||||
|
|
||||||
|
更新 renderer-facing item/task 类型,并在 `toState()` 中透传:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
localIcon?: string;
|
||||||
|
remoteIcon?: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 渲染层类型
|
||||||
|
|
||||||
|
修改:`src/global/typedefinition.ts`
|
||||||
|
|
||||||
|
更新 `UpdateCenterItem` 和 `UpdateCenterTaskState`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
localIcon?: string;
|
||||||
|
remoteIcon?: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 模块边界
|
||||||
|
|
||||||
|
### `electron/main/backend/update-center/icons.ts`
|
||||||
|
|
||||||
|
保留现有职责,但返回内容从“单个最终图标”调整为“两种候选来源”:
|
||||||
|
|
||||||
|
1. `resolveDesktopIcon(pkgname)`:解析传统 deb / aptss 更新项的本地图标。
|
||||||
|
2. `resolveApmIcon(pkgname)`:解析 APM 更新项的本地图标。
|
||||||
|
3. `buildRemoteFallbackIconUrl(item)`:拼接远程商店图标地址。
|
||||||
|
4. `resolveUpdateItemIcons(item)`:组合出 `{ localIcon?, remoteIcon? }`。
|
||||||
|
|
||||||
|
这里不再提前做“本地失败就直接放弃线上”的最终决策,而是把两个候选来源都准备好交给前端。
|
||||||
|
|
||||||
|
### `electron/main/backend/update-center/index.ts`
|
||||||
|
|
||||||
|
在更新项 enrichment 阶段,将:
|
||||||
|
|
||||||
|
1. 现有的单 `icon` 注入逻辑。
|
||||||
|
|
||||||
|
调整为:
|
||||||
|
|
||||||
|
1. 读取 `resolveUpdateItemIcons(item)` 的结果。
|
||||||
|
2. 仅在字段存在时把 `localIcon` / `remoteIcon` 写回更新项。
|
||||||
|
|
||||||
|
### `src/components/update-center/UpdateCenterItem.vue`
|
||||||
|
|
||||||
|
组件不再把单个 `item.icon` 当成最终地址,而是:
|
||||||
|
|
||||||
|
1. 从 `item.localIcon` 和 `item.remoteIcon` 派生候选列表。
|
||||||
|
2. 使用当前索引决定 `img.src`。
|
||||||
|
3. 失败时切到下一候选项。
|
||||||
|
4. 候选项耗尽后切到占位图。
|
||||||
|
|
||||||
|
## 详细数据流
|
||||||
|
|
||||||
|
### 主进程加载更新项
|
||||||
|
|
||||||
|
1. 更新中心主进程加载更新项。
|
||||||
|
2. 现有逻辑继续补齐 `category`、`arch` 等字段。
|
||||||
|
3. 图标模块为每个项分别解析:
|
||||||
|
- `localIcon`:本地图标路径。
|
||||||
|
- `remoteIcon`:线上图标 URL。
|
||||||
|
4. enrichment 后的更新项通过 service snapshot 发送到渲染层。
|
||||||
|
|
||||||
|
### 渲染层展示更新项
|
||||||
|
|
||||||
|
1. 组件收到 `item.localIcon` / `item.remoteIcon`。
|
||||||
|
2. 组件构造一个有序候选列表:
|
||||||
|
- 本地路径转换为 `file://` URL。
|
||||||
|
- 远程 URL 原样使用。
|
||||||
|
3. 初始渲染第 1 个候选图标。
|
||||||
|
4. 如果 `img` 加载成功,流程结束,不再切换到下一项。
|
||||||
|
5. 如果 `img` 触发 `error`,索引递增,继续尝试下一候选图标。
|
||||||
|
6. 如果所有候选都失败,切换到占位图。
|
||||||
|
|
||||||
|
## 前端行为细节
|
||||||
|
|
||||||
|
### 候选列表生成规则
|
||||||
|
|
||||||
|
候选列表只包含存在且非空的来源:
|
||||||
|
|
||||||
|
1. `localIcon` 存在时放在第 1 位。
|
||||||
|
2. `remoteIcon` 存在时放在第 2 位。
|
||||||
|
3. 占位图不放入候选列表,而是在候选耗尽后单独回退。
|
||||||
|
|
||||||
|
这样可以避免:
|
||||||
|
|
||||||
|
1. 本地图标成功时还额外发起线上请求。
|
||||||
|
2. 图标字段为空时出现无意义的重试。
|
||||||
|
|
||||||
|
### 状态重置规则
|
||||||
|
|
||||||
|
当 `props.item` 变为新的更新项对象时:
|
||||||
|
|
||||||
|
1. 重置当前候选索引到第 1 项。
|
||||||
|
2. 清空“候选已耗尽”的状态。
|
||||||
|
3. 重新开始本地优先的尝试流程。
|
||||||
|
|
||||||
|
这样可确保列表复用或重新渲染时,新条目不会继承上一条目的失败状态。
|
||||||
|
|
||||||
|
### 占位图规则
|
||||||
|
|
||||||
|
保留当前组件内默认占位 SVG,不改样式和尺寸。
|
||||||
|
|
||||||
|
只有在以下情况下才使用占位图:
|
||||||
|
|
||||||
|
1. `localIcon` 和 `remoteIcon` 都不存在。
|
||||||
|
2. `localIcon` 加载失败且 `remoteIcon` 不存在。
|
||||||
|
3. `localIcon` 和 `remoteIcon` 都加载失败。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
1. 本地图标路径不存在或不可读:允许浏览器触发加载失败,再由前端切到线上图标。
|
||||||
|
2. 远程图标返回 404、超时或其他加载错误:前端切到占位图,不向用户弹额外错误。
|
||||||
|
3. 后端无法推断 `category` 或 `arch`:允许 `remoteIcon` 为空,前端只尝试本地图标和占位图。
|
||||||
|
4. 任一图标来源失败都不能影响更新列表正文、状态标签和进度条显示。
|
||||||
|
|
||||||
|
## 测试方案
|
||||||
|
|
||||||
|
### 后端测试
|
||||||
|
|
||||||
|
扩展 `src/__tests__/unit/update-center/icons.test.ts`:
|
||||||
|
|
||||||
|
1. 本地图标可解析时,`resolveUpdateItemIcons()` 返回 `localIcon`,并在条件满足时同时包含 `remoteIcon`。
|
||||||
|
2. 本地图标缺失时,仍可返回 `remoteIcon`。
|
||||||
|
3. 缺少 `category` 或 `arch` 时,不返回 `remoteIcon`。
|
||||||
|
4. 两者都不可得时,返回空对象。
|
||||||
|
|
||||||
|
### 组件测试
|
||||||
|
|
||||||
|
扩展 `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`:
|
||||||
|
|
||||||
|
1. 有 `localIcon` 时先渲染本地 `file://` 地址。
|
||||||
|
2. 本地图标未失败前,不切换到 `remoteIcon`。
|
||||||
|
3. 本地图标触发 `error` 后切到 `remoteIcon`。
|
||||||
|
4. 本地和线上都触发 `error` 后切到默认占位图。
|
||||||
|
5. 切换到新的 `item` 后,回退状态会重置。
|
||||||
|
|
||||||
|
## 风险与约束
|
||||||
|
|
||||||
|
1. 如果某些包的本地图标路径在后端看来存在,但渲染进程实际不可访问,仍会触发一次失败请求;这是预期行为,因为它正是继续尝试线上图标的触发条件。
|
||||||
|
2. 远程图标 URL 继续依赖当前商店路径规则,若个别包没有线上图标,最终仍会使用占位图。
|
||||||
|
3. 本次只调整更新中心图标链路,不同步抽象其他页面,避免扩大改动范围。
|
||||||
|
|
||||||
|
## 决策总结
|
||||||
|
|
||||||
|
1. 用 `localIcon` 和 `remoteIcon` 替代单个 `icon` 字段。
|
||||||
|
2. 主进程负责解析来源,渲染层负责按顺序加载和失败回退。
|
||||||
|
3. 固定回退顺序为:本地图标 -> 线上图标 -> 默认占位图。
|
||||||
|
4. 本地图标成功时,不再加载线上图标和默认图标。
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# 更新中心 Spark 更新命令设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前 Electron 更新中心对 `aptss` 来源的更新项仍保留一条旧路径:当任务没有本地下载文件时,直接执行 `shell-caller.sh aptss install -y <pkg> --only-upgrade`。这条路径会在宿主系统里直接升级软件包,但不会复用 Qt 更新器已经采用的“先下载 deb,再通过 `ssinstall` 安装”的流程。
|
||||||
|
|
||||||
|
仓库里已经存在一个更贴近更新器预期的行为参考:旧 Qt 更新器在安装 `aptss` 来源更新时,会对下载好的 deb 调用 `ssinstall`,并带上“不创建桌面快捷方式”和“安装后删除下载文件”等参数。
|
||||||
|
|
||||||
|
本次需求是:仅对 Electron 更新中心生效,把 Spark 软件包更新改为走 `shell-caller` 顶层 `ssinstall` 路径,同时避免更新时创建新的桌面项。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
1. Electron 更新中心处理 `aptss` 更新时,统一改为“下载 deb -> `shell-caller.sh ssinstall` 安装”。
|
||||||
|
2. 更新时传入 `ssinstall` 的“不创建桌面项”参数,避免更新流程额外生成桌面快捷方式。
|
||||||
|
3. 变更只作用于 Electron 更新中心,不影响普通安装流、APM 更新流和 `extras/shell-caller.sh` 的白名单行为。
|
||||||
|
4. 继续沿用现有提权方式:若存在 `pkexec`,仍通过 `pkexec /opt/spark-store/extras/shell-caller.sh ...` 执行。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
1. 不修改 `electron/main/backend/install-manager.ts` 的普通安装逻辑。
|
||||||
|
2. 不修改 `apm` 来源更新的下载与安装方式。
|
||||||
|
3. 不扩展 `extras/shell-caller.sh` 以支持新的 `aptss ssinstall` 子命令形式。
|
||||||
|
4. 不修改旧 Qt 更新器行为;它只作为现有参考实现。
|
||||||
|
|
||||||
|
## 已确认的命令约束
|
||||||
|
|
||||||
|
### shell-caller 约束
|
||||||
|
|
||||||
|
当前仓库内的 `extras/shell-caller.sh` 只支持 3 个顶层命令类型:
|
||||||
|
|
||||||
|
1. `apm`
|
||||||
|
2. `aptss`
|
||||||
|
3. `ssinstall`
|
||||||
|
|
||||||
|
其中 `aptss` 仅允许 `install` 和 `remove` 两个子命令,不支持 `aptss ssinstall ...`。因此,本次实现不会尝试新增 `shell-caller aptss ssinstall` 这种调用形式,而是直接使用已存在的顶层 `ssinstall` 入口。
|
||||||
|
|
||||||
|
### ssinstall 参数名
|
||||||
|
|
||||||
|
本机 `ssinstall --help` 显示的真实参数名是:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
--no-create-desktop-entry
|
||||||
|
```
|
||||||
|
|
||||||
|
因此,需求里口头表达的 `--no-create-desktop` 会在实现中落到 `--no-create-desktop-entry`,避免引入不存在的参数名。
|
||||||
|
|
||||||
|
## 现状问题
|
||||||
|
|
||||||
|
当前更新中心后端只有 APM 更新项会在刷新阶段补齐 `downloadUrl`、`fileName`、`size` 和 `sha512` 等下载元数据。`aptss` 更新项只来自 `apt list --upgradable` 的文本解析结果,因此:
|
||||||
|
|
||||||
|
1. `aptss` 更新项通常没有可下载 deb 的元数据。
|
||||||
|
2. 没有 deb 文件时,安装逻辑会退回旧的 `aptss install --only-upgrade` 命令。
|
||||||
|
3. 这使得 Electron 更新中心无法像 Qt 更新器那样稳定走 `ssinstall` 路径。
|
||||||
|
|
||||||
|
## 方案概览
|
||||||
|
|
||||||
|
采用“刷新阶段补齐 `aptss` 下载元数据,执行阶段统一走 `ssinstall`”的方案。
|
||||||
|
|
||||||
|
整体流程如下:
|
||||||
|
|
||||||
|
1. 刷新更新列表时,继续查询 `aptss` 的可升级包。
|
||||||
|
2. 对每个 `aptss` 更新项额外查询 `apt download --print-uris` 元数据。
|
||||||
|
3. 只有拿到 `downloadUrl` 和 `fileName` 的 `aptss` 更新项才进入最终更新列表。
|
||||||
|
4. 执行更新任务时,先下载对应 deb。
|
||||||
|
5. 下载完成后调用 `shell-caller.sh ssinstall <deb> --no-create-desktop-entry --delete-after-install`。
|
||||||
|
6. 若存在提权命令,则实际执行 `pkexec /opt/spark-store/extras/shell-caller.sh ssinstall ...`。
|
||||||
|
|
||||||
|
这样可以让 Electron 更新中心的 `aptss` 更新行为与 Qt 更新器保持一致,同时严格限定在更新中心内部,不影响商店其他安装入口。
|
||||||
|
|
||||||
|
## 模块变更
|
||||||
|
|
||||||
|
### 1. `electron/main/backend/update-center/index.ts`
|
||||||
|
|
||||||
|
新增 `aptss` 下载元数据补全逻辑,方式与现有 APM 元数据补全保持一致。
|
||||||
|
|
||||||
|
建议变更:
|
||||||
|
|
||||||
|
1. 新增一个 `aptss` 的 `print-uris` 命令构造函数,复用当前 `apt-fast` 配置与源列表参数。
|
||||||
|
2. 复用现有 `parsePrintUrisOutput()` 解析函数,不新增第二套解析器。
|
||||||
|
3. 为 `aptss` 更新项新增与 APM 相同的元数据补全过程。
|
||||||
|
4. 元数据查询失败的 `aptss` 项从最终可更新列表中剔除,并写入 warning。
|
||||||
|
|
||||||
|
这样做的原因是:更新中心一旦展示某个更新项,就应该能够实际完成下载和安装,而不是在任务执行阶段才发现缺少 deb 元数据。
|
||||||
|
|
||||||
|
### 2. `electron/main/backend/update-center/install.ts`
|
||||||
|
|
||||||
|
`aptss` 更新项的安装路径改为严格依赖已下载的 `filePath`。
|
||||||
|
|
||||||
|
行为调整:
|
||||||
|
|
||||||
|
1. `item.source === "aptss"` 且有 `filePath` 时,执行 `shell-caller.sh ssinstall`。
|
||||||
|
2. 传参为:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssinstall <deb-path> --no-create-desktop-entry --delete-after-install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 若存在 `superUserCmd`,则通过 `buildPrivilegedCommand()` 包装成:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/usr/bin/pkexec /opt/spark-store/extras/shell-caller.sh ssinstall <deb-path> --no-create-desktop-entry --delete-after-install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 删除 `aptss` 无文件时回退到 `buildLegacySparkUpgradeCommand()` 的行为。
|
||||||
|
|
||||||
|
这意味着 `aptss` 更新不再允许悄悄退回旧式 `aptss install --only-upgrade` 流程。
|
||||||
|
|
||||||
|
### 3. 其他模块
|
||||||
|
|
||||||
|
以下模块不应发生行为变化:
|
||||||
|
|
||||||
|
1. `electron/main/backend/install-manager.ts`
|
||||||
|
2. `extras/shell-caller.sh`
|
||||||
|
3. `spark-update-tool/` 中的 Qt 更新器逻辑
|
||||||
|
4. `apm` 来源更新的下载与安装分支
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
### 刷新阶段
|
||||||
|
|
||||||
|
1. 读取 `aptss` 和 `apm` 的可升级列表。
|
||||||
|
2. 读取已安装来源状态。
|
||||||
|
3. 为 `aptss` 更新项加载 deb 元数据。
|
||||||
|
4. 为 `apm` 更新项加载 deb 元数据。
|
||||||
|
5. 合并来源、迁移标记、图标和其他展示字段。
|
||||||
|
6. 返回只包含“可实际下载并安装”的更新项列表。
|
||||||
|
|
||||||
|
### 执行阶段
|
||||||
|
|
||||||
|
1. 任务进入 `downloading`。
|
||||||
|
2. 使用已有 aria2 下载器下载 deb。
|
||||||
|
3. 任务进入 `installing`。
|
||||||
|
4. `aptss` 项执行 `shell-caller.sh ssinstall`。
|
||||||
|
5. `apm` 项继续执行当前 `shell-caller.sh apm ssinstall` 流程。
|
||||||
|
6. 成功后标记完成,失败则保留日志与错误信息。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 刷新失败
|
||||||
|
|
||||||
|
如果某个 `aptss` 包的元数据查询失败:
|
||||||
|
|
||||||
|
1. 不让该项进入可更新列表。
|
||||||
|
2. 在 `warnings` 中记录具体失败信息,例如 `aptss metadata query for <pkg> failed ...`。
|
||||||
|
3. 不影响其他更新项展示。
|
||||||
|
|
||||||
|
### 安装失败
|
||||||
|
|
||||||
|
如果 `shell-caller.sh ssinstall ...` 返回非 0:
|
||||||
|
|
||||||
|
1. 保持当前任务失败处理逻辑不变。
|
||||||
|
2. 将 stdout/stderr 继续写入任务日志。
|
||||||
|
3. 由任务队列把该更新项标记为 `failed`。
|
||||||
|
|
||||||
|
### 取消任务
|
||||||
|
|
||||||
|
取消逻辑保持不变。只要下载或安装子进程被中止,任务仍按当前机制进入 `cancelled` 或 `failed` 分支,不额外引入新的取消状态。
|
||||||
|
|
||||||
|
## 测试方案
|
||||||
|
|
||||||
|
### 单元测试
|
||||||
|
|
||||||
|
先写失败测试,再改实现。至少覆盖以下场景:
|
||||||
|
|
||||||
|
1. `load-items.test.ts`
|
||||||
|
- `aptss` 更新项会额外查询 `print-uris` 元数据。
|
||||||
|
- 元数据成功时,结果包含 `downloadUrl` 和 `fileName`。
|
||||||
|
- 元数据失败时,该项被过滤并写入 warning。
|
||||||
|
|
||||||
|
2. `task-runner.test.ts`
|
||||||
|
- `aptss` 文件安装走 `shell-caller.sh ssinstall <deb> --no-create-desktop-entry --delete-after-install`。
|
||||||
|
- 不再断言旧的 `buildLegacySparkUpgradeCommand()` 输出。
|
||||||
|
- `apm` 文件安装仍走 `shell-caller.sh apm ssinstall <deb>`,避免回归。
|
||||||
|
|
||||||
|
3. 如有必要,为安装构造函数补充更细粒度测试,确保带 `superUserCmd` 时参数顺序正确。
|
||||||
|
|
||||||
|
### 验证命令
|
||||||
|
|
||||||
|
实现完成后至少执行:
|
||||||
|
|
||||||
|
1. `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/task-runner.test.ts`
|
||||||
|
2. `npm run lint`
|
||||||
|
3. `npm run build`
|
||||||
|
|
||||||
|
## 风险与约束
|
||||||
|
|
||||||
|
1. `aptss` 元数据查询会为每个更新项新增一次命令调用,刷新成本会增加,但这是换取 updater-only `ssinstall` 行为所必需的最小代价。
|
||||||
|
2. 若某些仓库源对 `apt download --print-uris` 返回格式异常,相关更新项会被过滤并显示 warning;这比静默退回旧命令更符合本次需求。
|
||||||
|
3. `shell-caller.sh ssinstall` 会自动补上 `--native`,因此更新中心无需重复传入该参数。
|
||||||
|
|
||||||
|
## 决策总结
|
||||||
|
|
||||||
|
1. Electron 更新中心的 `aptss` 更新改为“下载 deb 后通过顶层 `shell-caller.sh ssinstall` 安装”。
|
||||||
|
2. 实际使用的桌面项参数名为 `--no-create-desktop-entry`。
|
||||||
|
3. 删除 `aptss` 更新回退到 `aptss install --only-upgrade` 的旧行为。
|
||||||
|
4. 该变更只作用于 `electron/main/backend/update-center/`,不修改其他安装入口。
|
||||||
Reference in New Issue
Block a user