mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-26 01:10:16 +08:00
feat(update-center): add update list icons
This commit is contained in:
674
docs/superpowers/plans/2026-04-10-update-center-icons.md
Normal file
674
docs/superpowers/plans/2026-04-10-update-center-icons.md
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
# Update Center Icons 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:** Add icons to the Electron update-center list using local icon resolution first, remote URL fallback second, and a frontend placeholder last.
|
||||||
|
|
||||||
|
**Architecture:** Add a focused `icons.ts` helper in the update-center backend to resolve icon paths/URLs while loading update items, then pass the single `icon` field through the service snapshot into the renderer. Keep the Vue side minimal by rendering a fixed icon slot in `UpdateCenterItem.vue` and falling back to a placeholder icon on `img` load failure.
|
||||||
|
|
||||||
|
**Tech Stack:** Electron main process, Node.js `fs`/`path`, Vue 3 `<script setup>`, Tailwind CSS 4, Vitest, Testing Library Vue, TypeScript strict mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Create: `electron/main/backend/update-center/icons.ts` — resolves update-item icons from local desktop/APM metadata and remote fallback URLs.
|
||||||
|
- Modify: `electron/main/backend/update-center/types.ts` — add backend `icon?: string` field.
|
||||||
|
- Modify: `electron/main/backend/update-center/index.ts` — enrich loaded update items with resolved icons.
|
||||||
|
- Modify: `electron/main/backend/update-center/service.ts` — expose `icon` in renderer-facing snapshots.
|
||||||
|
- Modify: `src/global/typedefinition.ts` — add renderer-facing `icon?: string` field.
|
||||||
|
- Modify: `src/components/update-center/UpdateCenterItem.vue` — render icon slot and placeholder fallback.
|
||||||
|
- Test: `src/__tests__/unit/update-center/icons.test.ts` — backend icon-resolution tests.
|
||||||
|
- Modify: `src/__tests__/unit/update-center/load-items.test.ts` — verify loaded update items include icon data when available.
|
||||||
|
- Create: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts` — component-level icon rendering and fallback tests.
|
||||||
|
|
||||||
|
### Task 1: Add Backend Icon Resolution Helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `electron/main/backend/update-center/icons.ts`
|
||||||
|
- Modify: `electron/main/backend/update-center/types.ts`
|
||||||
|
- Test: `src/__tests__/unit/update-center/icons.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildRemoteFallbackIconUrl,
|
||||||
|
resolveApmIcon,
|
||||||
|
resolveDesktopIcon,
|
||||||
|
} from "../../../../electron/main/backend/update-center/icons";
|
||||||
|
|
||||||
|
describe("update-center icons", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers local desktop icon paths for aptss items", () => {
|
||||||
|
const existsSync = vi.spyOn(require("node:fs"), "existsSync");
|
||||||
|
const readdirSync = vi.spyOn(require("node:fs"), "readdirSync");
|
||||||
|
const readFileSync = vi.spyOn(require("node:fs"), "readFileSync");
|
||||||
|
|
||||||
|
existsSync.mockImplementation((target) =>
|
||||||
|
String(target).includes("/usr/share/applications"),
|
||||||
|
);
|
||||||
|
readdirSync.mockReturnValue(["spark-weather.desktop"]);
|
||||||
|
readFileSync.mockReturnValue(
|
||||||
|
"Name=Spark Weather\nIcon=/usr/share/icons/hicolor/128x128/apps/spark-weather.png\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolveDesktopIcon("spark-weather")).toBe(
|
||||||
|
"/usr/share/icons/hicolor/128x128/apps/spark-weather.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves APM icon names from entries/icons when desktop icon is not absolute", () => {
|
||||||
|
const existsSync = vi.spyOn(require("node:fs"), "existsSync");
|
||||||
|
const readdirSync = vi.spyOn(require("node:fs"), "readdirSync");
|
||||||
|
const readFileSync = vi.spyOn(require("node:fs"), "readFileSync");
|
||||||
|
|
||||||
|
existsSync.mockImplementation(
|
||||||
|
(target) =>
|
||||||
|
String(target).includes(
|
||||||
|
"/var/lib/apm/apm/files/ace-env/var/lib/apm/com.qihoo.360zip/entries/icons/hicolor/48x48/apps/360zip.png",
|
||||||
|
) ||
|
||||||
|
String(target).includes(
|
||||||
|
"/var/lib/apm/apm/files/ace-env/var/lib/apm/com.qihoo.360zip/entries/applications",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
readdirSync.mockReturnValue(["360zip.desktop"]);
|
||||||
|
readFileSync.mockReturnValue("Name=360压缩\nIcon=360zip\n");
|
||||||
|
|
||||||
|
expect(resolveApmIcon("com.qihoo.360zip")).toBe(
|
||||||
|
"/var/lib/apm/apm/files/ace-env/var/lib/apm/com.qihoo.360zip/entries/icons/hicolor/48x48/apps/360zip.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a remote fallback URL when category and arch are available", () => {
|
||||||
|
expect(
|
||||||
|
buildRemoteFallbackIconUrl({
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
source: "aptss",
|
||||||
|
arch: "amd64",
|
||||||
|
category: "network",
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
"https://erotica.spark-app.store/amd64-store/network/spark-weather/icon.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string when neither local nor remote icon can be determined", () => {
|
||||||
|
expect(
|
||||||
|
buildRemoteFallbackIconUrl({
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
source: "aptss",
|
||||||
|
arch: "amd64",
|
||||||
|
}),
|
||||||
|
).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL with `Cannot find module '../../../../electron/main/backend/update-center/icons'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// electron/main/backend/update-center/types.ts
|
||||||
|
export interface UpdateCenterItem {
|
||||||
|
pkgname: string;
|
||||||
|
source: UpdateSource;
|
||||||
|
currentVersion: string;
|
||||||
|
nextVersion: string;
|
||||||
|
icon?: string;
|
||||||
|
ignored?: boolean;
|
||||||
|
downloadUrl?: string;
|
||||||
|
fileName?: string;
|
||||||
|
size?: number;
|
||||||
|
sha512?: string;
|
||||||
|
isMigration?: boolean;
|
||||||
|
migrationSource?: UpdateSource;
|
||||||
|
migrationTarget?: UpdateSource;
|
||||||
|
aptssVersion?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// electron/main/backend/update-center/icons.ts
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const APM_STORE_BASE_URL = "https://erotica.spark-app.store";
|
||||||
|
const APM_BASE_PATH = "/var/lib/apm/apm/files/ace-env/var/lib/apm";
|
||||||
|
|
||||||
|
export const resolveDesktopIcon = (pkgname: string): string => {
|
||||||
|
const desktopRoots = [
|
||||||
|
"/usr/share/applications",
|
||||||
|
`/opt/apps/${pkgname}/entries/applications`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const root of desktopRoots) {
|
||||||
|
if (!fs.existsSync(root)) continue;
|
||||||
|
for (const file of fs.readdirSync(root)) {
|
||||||
|
if (!file.endsWith(".desktop")) continue;
|
||||||
|
const content = fs.readFileSync(path.join(root, file), "utf8");
|
||||||
|
const match = content.match(/^Icon=(.+)$/m);
|
||||||
|
if (!match) continue;
|
||||||
|
const iconValue = match[1].trim();
|
||||||
|
if (iconValue.startsWith("/")) return iconValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveApmIcon = (pkgname: string): string => {
|
||||||
|
const entriesPath = path.join(
|
||||||
|
APM_BASE_PATH,
|
||||||
|
pkgname,
|
||||||
|
"entries",
|
||||||
|
"applications",
|
||||||
|
);
|
||||||
|
if (!fs.existsSync(entriesPath)) return "";
|
||||||
|
|
||||||
|
for (const file of fs.readdirSync(entriesPath)) {
|
||||||
|
if (!file.endsWith(".desktop")) continue;
|
||||||
|
const content = fs.readFileSync(path.join(entriesPath, file), "utf8");
|
||||||
|
const match = content.match(/^Icon=(.+)$/m);
|
||||||
|
if (!match) continue;
|
||||||
|
const iconValue = match[1].trim();
|
||||||
|
if (iconValue.startsWith("/")) return iconValue;
|
||||||
|
|
||||||
|
const iconPath = path.join(
|
||||||
|
APM_BASE_PATH,
|
||||||
|
pkgname,
|
||||||
|
"entries",
|
||||||
|
"icons",
|
||||||
|
"hicolor",
|
||||||
|
"48x48",
|
||||||
|
"apps",
|
||||||
|
`${iconValue}.png`,
|
||||||
|
);
|
||||||
|
if (fs.existsSync(iconPath)) return iconPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildRemoteFallbackIconUrl = (input: {
|
||||||
|
pkgname: string;
|
||||||
|
source: "aptss" | "apm";
|
||||||
|
arch: string;
|
||||||
|
category?: string;
|
||||||
|
}): string => {
|
||||||
|
if (!input.category) return "";
|
||||||
|
const finalArch =
|
||||||
|
input.source === "aptss" ? `${input.arch}-store` : `${input.arch}-apm`;
|
||||||
|
return `${APM_STORE_BASE_URL}/${finalArch}/${input.category}/${input.pkgname}/icon.png`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveUpdateItemIcon = (item: {
|
||||||
|
pkgname: string;
|
||||||
|
source: "aptss" | "apm";
|
||||||
|
arch?: string;
|
||||||
|
category?: string;
|
||||||
|
}): string => {
|
||||||
|
const localIcon =
|
||||||
|
item.source === "apm"
|
||||||
|
? resolveApmIcon(item.pkgname)
|
||||||
|
: resolveDesktopIcon(item.pkgname);
|
||||||
|
|
||||||
|
if (localIcon) {
|
||||||
|
return localIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.arch) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildRemoteFallbackIconUrl({
|
||||||
|
pkgname: item.pkgname,
|
||||||
|
source: item.source,
|
||||||
|
arch: item.arch,
|
||||||
|
category: item.category,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS with 4 tests passed.
|
||||||
|
|
||||||
|
- [ ] **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 "feat(update-center): add icon resolution helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Enrich Loaded Update Items with Icons
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `electron/main/backend/update-center/index.ts`
|
||||||
|
- Modify: `electron/main/backend/update-center/service.ts`
|
||||||
|
- Modify: `src/global/typedefinition.ts`
|
||||||
|
- Modify: `src/__tests__/unit/update-center/load-items.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../../../electron/main/backend/update-center/icons", () => ({
|
||||||
|
resolveUpdateItemIcon: vi.fn((item) =>
|
||||||
|
item.pkgname === "spark-weather"
|
||||||
|
? "/usr/share/icons/hicolor/128x128/apps/spark-weather.png"
|
||||||
|
: "",
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { loadUpdateCenterItems } from "../../../../electron/main/backend/update-center";
|
||||||
|
|
||||||
|
describe("update-center load items", () => {
|
||||||
|
it("adds icon data to loaded update items", async () => {
|
||||||
|
const result = await loadUpdateCenterItems(async (command, args) => {
|
||||||
|
const key = `${command} ${args.join(" ")}`;
|
||||||
|
if (key.includes("list --upgradable")) {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
stdout: "spark-weather/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.includes("dpkg-query")) {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
stdout: "spark-weather\tinstall ok installed\n",
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code: 0, stdout: "", stderr: "" };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
pkgname: "spark-weather",
|
||||||
|
icon: "/usr/share/icons/hicolor/128x128/apps/spark-weather.png",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because loaded items do not yet include `icon`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// electron/main/backend/update-center/index.ts
|
||||||
|
import { resolveUpdateItemIcon } from "./icons";
|
||||||
|
|
||||||
|
const withResolvedIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => {
|
||||||
|
return items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
icon: resolveUpdateItemIcon(item),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadUpdateCenterItems = async (
|
||||||
|
runCommand: UpdateCenterCommandRunner = runCommandCapture,
|
||||||
|
): Promise<UpdateCenterLoadItemsResult> => {
|
||||||
|
const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] =
|
||||||
|
await Promise.all([
|
||||||
|
runCommand(
|
||||||
|
APTSS_LIST_UPGRADABLE_COMMAND.command,
|
||||||
|
APTSS_LIST_UPGRADABLE_COMMAND.args,
|
||||||
|
),
|
||||||
|
runCommand("apm", ["list", "--upgradable"]),
|
||||||
|
runCommand(
|
||||||
|
DPKG_QUERY_INSTALLED_COMMAND.command,
|
||||||
|
DPKG_QUERY_INSTALLED_COMMAND.args,
|
||||||
|
),
|
||||||
|
runCommand("apm", ["list", "--installed"]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const warnings = [
|
||||||
|
getCommandError("aptss upgradable query", aptssResult),
|
||||||
|
getCommandError("apm upgradable query", apmResult),
|
||||||
|
getCommandError("dpkg installed query", aptssInstalledResult),
|
||||||
|
getCommandError("apm installed query", apmInstalledResult),
|
||||||
|
].filter((message): message is string => message !== null);
|
||||||
|
|
||||||
|
const aptssItems =
|
||||||
|
aptssResult.code === 0
|
||||||
|
? parseAptssUpgradableOutput(aptssResult.stdout)
|
||||||
|
: [];
|
||||||
|
const apmItems =
|
||||||
|
apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : [];
|
||||||
|
|
||||||
|
if (aptssResult.code !== 0 && apmResult.code !== 0) {
|
||||||
|
throw new Error(warnings.join("; "));
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedSources = buildInstalledSourceMap(
|
||||||
|
aptssInstalledResult.code === 0 ? aptssInstalledResult.stdout : "",
|
||||||
|
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const enrichedApmItems = await enrichApmItems(apmItems, runCommand);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: withResolvedIcons(
|
||||||
|
mergeUpdateSources(aptssItems, enrichedApmItems.items, installedSources),
|
||||||
|
),
|
||||||
|
warnings: [...warnings, ...enrichedApmItems.warnings],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// electron/main/backend/update-center/service.ts
|
||||||
|
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,
|
||||||
|
icon: item.icon,
|
||||||
|
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,
|
||||||
|
status: task.status,
|
||||||
|
progress: task.progress,
|
||||||
|
logs: task.logs.map((log) => ({ ...log })),
|
||||||
|
errorMessage: task.error ?? "",
|
||||||
|
})),
|
||||||
|
warnings: [...snapshot.warnings],
|
||||||
|
hasRunningTasks: snapshot.hasRunningTasks,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/global/typedefinition.ts
|
||||||
|
export interface UpdateCenterItem {
|
||||||
|
taskKey: string;
|
||||||
|
packageName: string;
|
||||||
|
displayName: string;
|
||||||
|
currentVersion: string;
|
||||||
|
newVersion: string;
|
||||||
|
source: UpdateSource;
|
||||||
|
icon?: string;
|
||||||
|
ignored?: boolean;
|
||||||
|
downloadUrl?: string;
|
||||||
|
fileName?: string;
|
||||||
|
size?: number;
|
||||||
|
sha512?: string;
|
||||||
|
isMigration?: boolean;
|
||||||
|
migrationSource?: UpdateSource;
|
||||||
|
migrationTarget?: UpdateSource;
|
||||||
|
aptssVersion?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS with icon assertions included.
|
||||||
|
|
||||||
|
- [ ] **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
|
||||||
|
git commit -m "feat(update-center): pass resolved icons to renderer"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Render Update-List Icons with Placeholder Fallback
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/components/update-center/UpdateCenterItem.vue`
|
||||||
|
- Create: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import UpdateCenterItem from "@/components/update-center/UpdateCenterItem.vue";
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
taskKey: "aptss:spark-weather",
|
||||||
|
packageName: "spark-weather",
|
||||||
|
displayName: "Spark Weather",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
newVersion: "2.0.0",
|
||||||
|
source: "aptss" as const,
|
||||||
|
icon: "/usr/share/icons/hicolor/128x128/apps/spark-weather.png",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("UpdateCenterItem", () => {
|
||||||
|
it("renders an icon image when item.icon exists", () => {
|
||||||
|
render(UpdateCenterItem, {
|
||||||
|
props: { item, selected: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const image = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||||
|
expect(image.getAttribute("src")).toBe(
|
||||||
|
"file:///usr/share/icons/hicolor/128x128/apps/spark-weather.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a placeholder icon when the image fails", async () => {
|
||||||
|
render(UpdateCenterItem, {
|
||||||
|
props: { item, selected: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const image = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||||
|
await fireEvent.error(image);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("update-center-icon-fallback")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because `UpdateCenterItem.vue` does not render icon markup yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- src/components/update-center/UpdateCenterItem.vue -->
|
||||||
|
<template>
|
||||||
|
<label
|
||||||
|
class="flex flex-col gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
|
||||||
|
:checked="selected"
|
||||||
|
:disabled="item.ignored === true"
|
||||||
|
@change="$emit('toggle-selection')"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="resolvedIcon && !iconFailed"
|
||||||
|
:src="resolvedIcon"
|
||||||
|
:alt="`${item.displayName} 图标`"
|
||||||
|
class="h-8 w-8 object-contain"
|
||||||
|
@error="iconFailed = true"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
v-else
|
||||||
|
data-testid="update-center-icon-fallback"
|
||||||
|
class="fas fa-cube text-lg text-slate-400"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<p class="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{{ item.displayName }}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
{{ sourceLabel }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.isMigration"
|
||||||
|
class="rounded-full bg-brand/10 px-2.5 py-1 text-xs font-semibold text-brand"
|
||||||
|
>
|
||||||
|
将迁移到 APM
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.ignored === true"
|
||||||
|
class="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
已忽略
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{{ item.packageName }} · 当前 {{ item.currentVersion }} · 更新至
|
||||||
|
{{ item.newVersion }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="item.ignored === true"
|
||||||
|
class="mt-2 text-xs font-medium text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
已忽略的更新不会加入本次任务。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="task"
|
||||||
|
class="text-right text-sm font-semibold text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<p>{{ statusLabel }}</p>
|
||||||
|
<p v-if="showProgress" class="mt-1">{{ progressText }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showProgress" class="space-y-2">
|
||||||
|
<div
|
||||||
|
class="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-gradient-to-r from-brand to-brand-dark"
|
||||||
|
:style="progressStyle"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
UpdateCenterItem,
|
||||||
|
UpdateCenterTaskState,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: UpdateCenterItem;
|
||||||
|
task?: UpdateCenterTaskState;
|
||||||
|
selected: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const iconFailed = ref(false);
|
||||||
|
|
||||||
|
const resolvedIcon = computed(() => {
|
||||||
|
if (!props.item.icon) return "";
|
||||||
|
return props.item.icon.startsWith("/")
|
||||||
|
? `file://${props.item.icon}`
|
||||||
|
: props.item.icon;
|
||||||
|
});
|
||||||
|
</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 2 tests passed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/update-center/UpdateCenterItem.vue src/__tests__/unit/update-center/UpdateCenterItem.test.ts
|
||||||
|
git commit -m "feat(update-center): render update item icons"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Verify the Icon Feature End-to-End
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `electron/main/backend/update-center/icons.ts`
|
||||||
|
- Modify: `electron/main/backend/update-center/index.ts`
|
||||||
|
- Modify: `electron/main/backend/update-center/service.ts`
|
||||||
|
- Modify: `src/global/typedefinition.ts`
|
||||||
|
- Modify: `src/components/update-center/UpdateCenterItem.vue`
|
||||||
|
- Modify: `src/__tests__/unit/update-center/icons.test.ts`
|
||||||
|
- Modify: `src/__tests__/unit/update-center/load-items.test.ts`
|
||||||
|
- Modify: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Format the changed files**
|
||||||
|
|
||||||
|
Run: `npm run format`
|
||||||
|
|
||||||
|
Expected: Prettier rewrites changed `src/` and `electron/` files without errors.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint and the targeted update-center suite**
|
||||||
|
|
||||||
|
Run: `npm run lint && 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/UpdateCenterItem.test.ts`
|
||||||
|
|
||||||
|
Expected: ESLint exits 0 and the new icon-related tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the complete unit suite and production build**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run && npm run build:vite`
|
||||||
|
|
||||||
|
Expected: all existing unit tests remain green and `vue-tsc` plus Vite production build complete successfully.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the verified icon feature**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add electron/main/backend/update-center/types.ts electron/main/backend/update-center/icons.ts electron/main/backend/update-center/index.ts electron/main/backend/update-center/service.ts src/global/typedefinition.ts src/components/update-center/UpdateCenterItem.vue src/__tests__/unit/update-center/icons.test.ts src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/UpdateCenterItem.test.ts
|
||||||
|
git commit -m "feat(update-center): show icons in update list"
|
||||||
|
```
|
||||||
214
docs/superpowers/specs/2026-04-10-update-center-icons-design.md
Normal file
214
docs/superpowers/specs/2026-04-10-update-center-icons-design.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# 更新中心列表图标设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前 Electron 更新中心已经可以展示更新项、来源、迁移标记、进度和日志,但更新列表仍然只有文字信息,没有应用图标。对于 APM 包、传统 deb 包和迁移项,纯文字列表会降低识别效率,尤其在批量更新和搜索场景下不够直观。
|
||||||
|
|
||||||
|
仓库现状里已经存在多套可复用的图标来源逻辑:
|
||||||
|
|
||||||
|
1. 主商店卡片通过远程商店 URL 拼接 `icon.png`。
|
||||||
|
2. 已安装应用列表支持本地图标和远程 URL 双来源。
|
||||||
|
3. 旧 Qt 更新器会为 APM 更新项解析 desktop 与 entries/icons,并在无本地图标时继续使用其他数据源。
|
||||||
|
|
||||||
|
目标是在更新中心列表中加入应用图标,同时保持最小改动、兼容当前后端结构,并遵循“本地解析优先,其次远程 URL,最后占位图标”的策略。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
1. 在更新中心列表中为每个更新项展示应用图标。
|
||||||
|
2. 图标来源优先级为:本地解析 > 远程 URL > 前端占位图标。
|
||||||
|
3. 前后端仅增加一个最小公共字段,不引入复杂的图标对象结构。
|
||||||
|
4. 图标缺失或加载失败时,界面仍然保持稳定、整齐、不闪烁。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
1. 不为图标来源新增额外网络探测请求。
|
||||||
|
2. 不在本次设计中重构应用详情页、已安装列表或主商店卡片的图标逻辑。
|
||||||
|
3. 不在 UI 中展示“图标来源”说明文字。
|
||||||
|
|
||||||
|
## 方案概览
|
||||||
|
|
||||||
|
采用“主进程解析来源、渲染层只展示”的方案:
|
||||||
|
|
||||||
|
1. 更新中心主进程在加载更新项时解析图标来源,并将结果写入更新项的 `icon` 字段。
|
||||||
|
2. 渲染层更新列表只消费 `item.icon`,不参与解析来源。
|
||||||
|
3. 前端负责单次图片加载失败回退到占位图标。
|
||||||
|
|
||||||
|
## 数据结构变化
|
||||||
|
|
||||||
|
### 主进程
|
||||||
|
|
||||||
|
修改:`electron/main/backend/update-center/types.ts`
|
||||||
|
|
||||||
|
为 `UpdateCenterItem` 增加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
icon?: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 渲染层
|
||||||
|
|
||||||
|
修改:`src/global/typedefinition.ts`
|
||||||
|
|
||||||
|
为 `UpdateCenterItem` 增加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
icon?: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service 映射
|
||||||
|
|
||||||
|
修改:`electron/main/backend/update-center/service.ts`
|
||||||
|
|
||||||
|
在主进程 snapshot -> renderer snapshot 的映射中透传 `icon` 字段。
|
||||||
|
|
||||||
|
## 图标来源策略
|
||||||
|
|
||||||
|
### 优先级
|
||||||
|
|
||||||
|
每个更新项统一按以下顺序取图标:
|
||||||
|
|
||||||
|
1. 本地图标路径
|
||||||
|
2. 远程商店图标 URL
|
||||||
|
3. 前端占位图标
|
||||||
|
|
||||||
|
### 1. 本地图标路径
|
||||||
|
|
||||||
|
#### 传统 deb / Spark 更新项
|
||||||
|
|
||||||
|
优先复用仓库中已有的 desktop 文件扫描与 `Icon=` 解析思路,来源参考:
|
||||||
|
|
||||||
|
- `electron/main/backend/install-manager.ts`
|
||||||
|
|
||||||
|
解析策略:
|
||||||
|
|
||||||
|
1. 从已安装包对应的 desktop 文件中读取 `Icon=`。
|
||||||
|
2. 如果解析结果为绝对路径,直接返回。
|
||||||
|
3. 如果解析结果为图标名,则尝试根据系统图标路径补全。
|
||||||
|
4. 若无法得到有效路径,则继续下一层来源。
|
||||||
|
|
||||||
|
#### APM 更新项
|
||||||
|
|
||||||
|
优先复用旧 Qt 更新器已存在的 APM 图标解析逻辑,来源参考:
|
||||||
|
|
||||||
|
- `spark-update-tool/src/aptssupdater.cpp`
|
||||||
|
|
||||||
|
解析策略:
|
||||||
|
|
||||||
|
1. 查找 APM 包的 `entries/applications/*.desktop`。
|
||||||
|
2. 从 desktop 的 `Icon=` 字段中解析图标。
|
||||||
|
3. 若 `Icon=` 为绝对路径,直接返回。
|
||||||
|
4. 若 `Icon=` 为图标名,则尝试拼接 APM 包内 `entries/icons/...` 路径。
|
||||||
|
5. 若仍无结果,则继续下一层来源。
|
||||||
|
|
||||||
|
### 2. 远程商店图标 URL
|
||||||
|
|
||||||
|
如果本地图标解析失败,则为更新项生成远程图标 URL。
|
||||||
|
|
||||||
|
实现原则:
|
||||||
|
|
||||||
|
1. 不主动探测 URL 是否可用。
|
||||||
|
2. 仅按现有商店规则拼接 URL,并交给浏览器加载。
|
||||||
|
3. 浏览器加载失败后由前端回退占位图标。
|
||||||
|
|
||||||
|
对 Spark/传统 deb:
|
||||||
|
|
||||||
|
1. 使用当前商店已有的远程图标拼接规则。
|
||||||
|
2. 若更新项可以推断出对应 category 和 arch,则拼接:
|
||||||
|
`${APM_STORE_BASE_URL}/${arch}/${category}/${pkgname}/icon.png`
|
||||||
|
|
||||||
|
对 APM:
|
||||||
|
|
||||||
|
1. 若仓库中已有 APM 对应商店资源约定,则使用同样的 `icon.png` 规则。
|
||||||
|
2. 若当前数据无法可靠推断 category,则允许直接跳过远程 URL,进入前端占位图标。
|
||||||
|
|
||||||
|
### 3. 占位图标
|
||||||
|
|
||||||
|
如果主进程未能提供 `icon`,或者前端加载失败,则使用统一占位图标。
|
||||||
|
|
||||||
|
占位规则:
|
||||||
|
|
||||||
|
1. 图标尺寸与正常图标一致。
|
||||||
|
2. 使用仓库现有品牌资源或统一默认应用图标。
|
||||||
|
3. 不因失败状态改变列表布局高度或间距。
|
||||||
|
|
||||||
|
## 模块边界
|
||||||
|
|
||||||
|
新增:
|
||||||
|
|
||||||
|
- `electron/main/backend/update-center/icons.ts`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. `resolveUpdateItemIcon()`
|
||||||
|
2. `resolveApmIcon()`
|
||||||
|
3. `resolveDesktopIcon()`
|
||||||
|
4. `buildRemoteFallbackIconUrl()`
|
||||||
|
|
||||||
|
该模块只负责“根据更新项得到一个 `icon?: string`”,不参与更新队列、安装、刷新、忽略等逻辑。
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
### 主进程加载更新项
|
||||||
|
|
||||||
|
1. 查询并合并更新项。
|
||||||
|
2. 对每个更新项执行图标解析。
|
||||||
|
3. 将解析到的 `icon` 字段写入 `UpdateCenterItem`。
|
||||||
|
4. 由 `service.ts` 将该字段透传到渲染层 snapshot。
|
||||||
|
|
||||||
|
### 渲染层展示
|
||||||
|
|
||||||
|
1. `UpdateCenterItem.vue` 读取 `item.icon`。
|
||||||
|
2. 如果 `item.icon` 为本地绝对路径,则转成 `file://` URL。
|
||||||
|
3. 如果 `item.icon` 为远程 URL,则直接作为图片地址使用。
|
||||||
|
4. 若图片加载失败,则切换为占位图标,并记住失败状态避免重复尝试。
|
||||||
|
|
||||||
|
## UI 设计
|
||||||
|
|
||||||
|
### 列表项布局
|
||||||
|
|
||||||
|
在更新列表中新增一个固定图标位:
|
||||||
|
|
||||||
|
1. 位置:复选框后、应用信息前。
|
||||||
|
2. 尺寸:`40x40`。
|
||||||
|
3. 样式:圆角矩形,视觉与商店应用卡片图标一致。
|
||||||
|
4. 图标位固定占位,避免有图和无图的项出现布局跳动。
|
||||||
|
|
||||||
|
### 失败回退
|
||||||
|
|
||||||
|
前端仅做一次失败回退:
|
||||||
|
|
||||||
|
1. 优先渲染 `item.icon`。
|
||||||
|
2. 触发 `@error` 后切换为占位图。
|
||||||
|
3. 记录该项失败状态,避免反复向无效地址重新请求。
|
||||||
|
|
||||||
|
## 测试方案
|
||||||
|
|
||||||
|
### 主进程测试
|
||||||
|
|
||||||
|
新增或扩展测试覆盖:
|
||||||
|
|
||||||
|
1. 本地图标优先于远程 URL。
|
||||||
|
2. APM 更新项可解析包内 desktop/icons。
|
||||||
|
3. 传统 deb 更新项可解析 desktop `Icon=`。
|
||||||
|
4. 无本地图标时能生成远程 URL 或返回空值。
|
||||||
|
|
||||||
|
### 组件测试
|
||||||
|
|
||||||
|
扩展 `UpdateCenterItem.vue` 组件测试:
|
||||||
|
|
||||||
|
1. 有 `item.icon` 时渲染图片。
|
||||||
|
2. 图片加载失败时回退到占位图。
|
||||||
|
3. 图标存在时不影响当前状态标签、迁移标签、进度条显示。
|
||||||
|
|
||||||
|
## 风险与约束
|
||||||
|
|
||||||
|
1. 更新项当前不一定总能推断出 category,因此远程 URL 兜底对部分项可能不可用;这是可接受的,因为前端还有占位图兜底。
|
||||||
|
2. 本地图标解析涉及多个来源路径,必须限制在读取路径和拼接路径,不做额外昂贵的同步探测。
|
||||||
|
3. APM 图标路径依赖当前系统安装结构,若个别包结构不标准,应直接退回远程或占位图,而不是阻断更新列表。
|
||||||
|
|
||||||
|
## 决策总结
|
||||||
|
|
||||||
|
1. 更新中心增加单字段 `icon?: string`,不引入复杂图标对象。
|
||||||
|
2. 主进程解析图标来源,渲染层只负责展示和失败回退。
|
||||||
|
3. 图标来源顺序固定为:本地解析 > 远程 URL > 占位图。
|
||||||
|
4. UI 仅新增稳定图标位,不改变现有更新列表信息层级。
|
||||||
201
electron/main/backend/update-center/icons.ts
Normal file
201
electron/main/backend/update-center/icons.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { UpdateCenterItem } from "./types";
|
||||||
|
|
||||||
|
const APM_BASE_PATH = "/var/lib/apm/apm/files/ace-env/var/lib/apm";
|
||||||
|
const REMOTE_ICON_BASE_URL = "https://erotica.spark-app.store";
|
||||||
|
|
||||||
|
const trimTrailingSlashes = (value: string): string =>
|
||||||
|
value.replace(/\/+$/, "");
|
||||||
|
|
||||||
|
const readDesktopIcon = (desktopPath: string): string => {
|
||||||
|
if (!fs.existsSync(desktopPath)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(desktopPath, "utf-8");
|
||||||
|
const iconMatch = content.match(/^Icon=(.+)$/m);
|
||||||
|
return iconMatch?.[1]?.trim() ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const listPackageFiles = (pkgname: string): Set<string> => {
|
||||||
|
const result = spawnSync("dpkg", ["-L", pkgname]);
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(
|
||||||
|
result.stdout
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDesktopIconInDirectories = (
|
||||||
|
directories: string[],
|
||||||
|
pkgname: string,
|
||||||
|
): string => {
|
||||||
|
const packageFiles = listPackageFiles(pkgname);
|
||||||
|
|
||||||
|
for (const directory of directories) {
|
||||||
|
if (!fs.existsSync(directory)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(directory)) {
|
||||||
|
if (!entry.endsWith(".desktop")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desktopPath = path.join(directory, entry);
|
||||||
|
if (
|
||||||
|
!desktopPath.startsWith(`/opt/apps/${pkgname}/`) &&
|
||||||
|
!packageFiles.has(desktopPath)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desktopIcon = readDesktopIcon(desktopPath);
|
||||||
|
if (!desktopIcon) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedIcon = resolveIconName(desktopIcon, [
|
||||||
|
`/usr/share/pixmaps/${desktopIcon}.png`,
|
||||||
|
`/usr/share/icons/hicolor/48x48/apps/${desktopIcon}.png`,
|
||||||
|
`/usr/share/icons/hicolor/scalable/apps/${desktopIcon}.svg`,
|
||||||
|
`/opt/apps/${pkgname}/entries/icons/hicolor/48x48/apps/${desktopIcon}.png`,
|
||||||
|
]);
|
||||||
|
if (resolvedIcon) {
|
||||||
|
return resolvedIcon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveIconName = (iconName: string, candidates: string[]): string => {
|
||||||
|
if (path.isAbsolute(iconName)) {
|
||||||
|
return fs.existsSync(iconName) ? iconName : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveDesktopIcon = (pkgname: string): string => {
|
||||||
|
return findDesktopIconInDirectories(
|
||||||
|
["/usr/share/applications", `/opt/apps/${pkgname}/entries/applications`],
|
||||||
|
pkgname,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveApmIcon = (pkgname: string): string => {
|
||||||
|
const apmRoots = [APM_BASE_PATH, "/opt/apps"];
|
||||||
|
|
||||||
|
for (const apmRoot of apmRoots) {
|
||||||
|
const desktopDirectory = path.join(
|
||||||
|
apmRoot,
|
||||||
|
pkgname,
|
||||||
|
"entries",
|
||||||
|
"applications",
|
||||||
|
);
|
||||||
|
if (!fs.existsSync(desktopDirectory)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const desktopFile of fs.readdirSync(desktopDirectory)) {
|
||||||
|
if (!desktopFile.endsWith(".desktop")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desktopIcon = readDesktopIcon(
|
||||||
|
path.join(desktopDirectory, desktopFile),
|
||||||
|
);
|
||||||
|
if (!desktopIcon) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedIcon = resolveIconName(desktopIcon, [
|
||||||
|
path.join(
|
||||||
|
apmRoot,
|
||||||
|
pkgname,
|
||||||
|
"entries",
|
||||||
|
"icons",
|
||||||
|
"hicolor",
|
||||||
|
"48x48",
|
||||||
|
"apps",
|
||||||
|
`${desktopIcon}.png`,
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
apmRoot,
|
||||||
|
pkgname,
|
||||||
|
"entries",
|
||||||
|
"icons",
|
||||||
|
"hicolor",
|
||||||
|
"scalable",
|
||||||
|
"apps",
|
||||||
|
`${desktopIcon}.svg`,
|
||||||
|
),
|
||||||
|
`/usr/share/pixmaps/${desktopIcon}.png`,
|
||||||
|
`/usr/share/icons/hicolor/48x48/apps/${desktopIcon}.png`,
|
||||||
|
`/usr/share/icons/hicolor/scalable/apps/${desktopIcon}.svg`,
|
||||||
|
]);
|
||||||
|
if (resolvedIcon) {
|
||||||
|
return resolvedIcon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildRemoteFallbackIconUrl = ({
|
||||||
|
pkgname,
|
||||||
|
source,
|
||||||
|
arch,
|
||||||
|
category,
|
||||||
|
}: Pick<
|
||||||
|
UpdateCenterItem,
|
||||||
|
"pkgname" | "source" | "arch" | "category"
|
||||||
|
>): string => {
|
||||||
|
const baseUrl = trimTrailingSlashes(REMOTE_ICON_BASE_URL);
|
||||||
|
if (!baseUrl || !arch || !category) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeArch = arch.includes("-")
|
||||||
|
? arch
|
||||||
|
: `${arch}-${source === "aptss" ? "store" : "apm"}`;
|
||||||
|
return `${baseUrl}/${storeArch}/${category}/${pkgname}/icon.png`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveUpdateItemIcon = (item: UpdateCenterItem): string => {
|
||||||
|
const localIcon =
|
||||||
|
item.source === "aptss"
|
||||||
|
? resolveDesktopIcon(item.pkgname)
|
||||||
|
: resolveApmIcon(item.pkgname);
|
||||||
|
if (localIcon) {
|
||||||
|
return localIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
buildRemoteFallbackIconUrl({
|
||||||
|
pkgname: item.pkgname,
|
||||||
|
source: item.source,
|
||||||
|
arch: item.arch,
|
||||||
|
category: item.category,
|
||||||
|
}) || ""
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
parseAptssUpgradableOutput,
|
parseAptssUpgradableOutput,
|
||||||
parsePrintUrisOutput,
|
parsePrintUrisOutput,
|
||||||
} from "./query";
|
} from "./query";
|
||||||
|
import { resolveUpdateItemIcon } from "./icons";
|
||||||
import {
|
import {
|
||||||
createUpdateCenterService,
|
createUpdateCenterService,
|
||||||
type UpdateCenterIgnorePayload,
|
type UpdateCenterIgnorePayload,
|
||||||
@@ -32,6 +33,15 @@ export interface UpdateCenterLoadItemsResult {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StoreCategoryMap = Map<string, string>;
|
||||||
|
|
||||||
|
interface RemoteCategoryAppEntry {
|
||||||
|
Pkgname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMOTE_STORE_BASE_URL = "https://erotica.spark-app.store";
|
||||||
|
const categoryCache = new Map<string, Promise<StoreCategoryMap>>();
|
||||||
|
|
||||||
const APTSS_LIST_UPGRADABLE_COMMAND = {
|
const APTSS_LIST_UPGRADABLE_COMMAND = {
|
||||||
command: "bash",
|
command: "bash",
|
||||||
args: [
|
args: [
|
||||||
@@ -146,6 +156,105 @@ const enrichApmItems = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStoreArch = (
|
||||||
|
item: Pick<UpdateCenterItem, "source" | "arch">,
|
||||||
|
): string => {
|
||||||
|
const arch = item.arch;
|
||||||
|
if (!arch) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arch.includes("-")) {
|
||||||
|
return arch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${arch}-${item.source === "aptss" ? "store" : "apm"}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadJson = async <T>(url: string): Promise<T> => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed for ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStoreCategoryMap = async (
|
||||||
|
storeArch: string,
|
||||||
|
): Promise<StoreCategoryMap> => {
|
||||||
|
const categories = await loadJson<Record<string, unknown>>(
|
||||||
|
`${REMOTE_STORE_BASE_URL}/${storeArch}/categories.json`,
|
||||||
|
);
|
||||||
|
const categoryEntries = await Promise.allSettled(
|
||||||
|
Object.keys(categories).map(async (category) => {
|
||||||
|
const apps = await loadJson<RemoteCategoryAppEntry[]>(
|
||||||
|
`${REMOTE_STORE_BASE_URL}/${storeArch}/${category}/applist.json`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { apps, category };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryMap: StoreCategoryMap = new Map();
|
||||||
|
for (const entry of categoryEntries) {
|
||||||
|
if (entry.status !== "fulfilled") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const app of entry.value.apps) {
|
||||||
|
if (app.Pkgname && !categoryMap.has(app.Pkgname)) {
|
||||||
|
categoryMap.set(app.Pkgname, entry.value.category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categoryMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStoreCategoryMap = (storeArch: string): Promise<StoreCategoryMap> => {
|
||||||
|
const cached = categoryCache.get(storeArch);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = loadStoreCategoryMap(storeArch).catch(() => {
|
||||||
|
categoryCache.delete(storeArch);
|
||||||
|
return new Map();
|
||||||
|
});
|
||||||
|
categoryCache.set(storeArch, pending);
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
const enrichItemCategories = async (
|
||||||
|
items: UpdateCenterItem[],
|
||||||
|
): Promise<UpdateCenterItem[]> => {
|
||||||
|
return await Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
if (item.category) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeArch = getStoreArch(item);
|
||||||
|
if (!storeArch) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryMap = await getStoreCategoryMap(storeArch);
|
||||||
|
const category = categoryMap.get(item.pkgname);
|
||||||
|
return category ? { ...item, category } : item;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enrichItemIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => {
|
||||||
|
return items.map((item) => {
|
||||||
|
const icon = resolveUpdateItemIcon(item);
|
||||||
|
|
||||||
|
return icon ? { ...item, icon } : item;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const loadUpdateCenterItems = async (
|
export const loadUpdateCenterItems = async (
|
||||||
runCommand: UpdateCenterCommandRunner = runCommandCapture,
|
runCommand: UpdateCenterCommandRunner = runCommandCapture,
|
||||||
): Promise<UpdateCenterLoadItemsResult> => {
|
): Promise<UpdateCenterLoadItemsResult> => {
|
||||||
@@ -186,12 +295,19 @@ export const loadUpdateCenterItems = async (
|
|||||||
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
|
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
|
||||||
);
|
);
|
||||||
|
|
||||||
const enrichedApmItems = await enrichApmItems(apmItems, runCommand);
|
const [categorizedAptssItems, categorizedApmItems] = await Promise.all([
|
||||||
|
enrichItemCategories(aptssItems),
|
||||||
|
enrichItemCategories(apmItems),
|
||||||
|
]);
|
||||||
|
const enrichedApmItems = await enrichApmItems(
|
||||||
|
categorizedApmItems,
|
||||||
|
runCommand,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: mergeUpdateSources(
|
items: mergeUpdateSources(
|
||||||
aptssItems,
|
enrichItemIcons(categorizedAptssItems),
|
||||||
enrichedApmItems.items,
|
enrichItemIcons(enrichedApmItems.items),
|
||||||
installedSources,
|
installedSources,
|
||||||
),
|
),
|
||||||
warnings: [...warnings, ...enrichedApmItems.warnings],
|
warnings: [...warnings, ...enrichedApmItems.warnings],
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ const parseUpgradableOutput = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [, pkgname, nextVersion, currentVersion] = match;
|
const [, pkgname, nextVersion, currentVersion] = match;
|
||||||
|
const arch = trimmed.split(/\s+/)[2];
|
||||||
if (!pkgname || nextVersion === currentVersion) {
|
if (!pkgname || nextVersion === currentVersion) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -210,6 +211,7 @@ const parseUpgradableOutput = (
|
|||||||
source,
|
source,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
nextVersion,
|
nextVersion,
|
||||||
|
arch,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface UpdateCenterServiceItem {
|
|||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
newVersion: string;
|
newVersion: string;
|
||||||
source: UpdateSource;
|
source: UpdateSource;
|
||||||
|
icon?: string;
|
||||||
ignored?: boolean;
|
ignored?: boolean;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
@@ -40,6 +41,7 @@ export interface UpdateCenterServiceTask {
|
|||||||
taskKey: string;
|
taskKey: string;
|
||||||
packageName: string;
|
packageName: string;
|
||||||
source: UpdateSource;
|
source: UpdateSource;
|
||||||
|
icon?: string;
|
||||||
status: UpdateCenterQueueSnapshot["tasks"][number]["status"];
|
status: UpdateCenterQueueSnapshot["tasks"][number]["status"];
|
||||||
progress: number;
|
progress: number;
|
||||||
logs: UpdateCenterQueueSnapshot["tasks"][number]["logs"];
|
logs: UpdateCenterQueueSnapshot["tasks"][number]["logs"];
|
||||||
@@ -96,6 +98,7 @@ const toState = (
|
|||||||
currentVersion: item.currentVersion,
|
currentVersion: item.currentVersion,
|
||||||
newVersion: item.nextVersion,
|
newVersion: item.nextVersion,
|
||||||
source: item.source,
|
source: item.source,
|
||||||
|
icon: item.icon,
|
||||||
ignored: item.ignored,
|
ignored: item.ignored,
|
||||||
downloadUrl: item.downloadUrl,
|
downloadUrl: item.downloadUrl,
|
||||||
fileName: item.fileName,
|
fileName: item.fileName,
|
||||||
@@ -110,6 +113,7 @@ const toState = (
|
|||||||
taskKey: getTaskKey(task.item),
|
taskKey: getTaskKey(task.item),
|
||||||
packageName: task.pkgname,
|
packageName: task.pkgname,
|
||||||
source: task.item.source,
|
source: task.item.source,
|
||||||
|
icon: task.item.icon,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
progress: task.progress,
|
progress: task.progress,
|
||||||
logs: task.logs.map((log) => ({ ...log })),
|
logs: task.logs.map((log) => ({ ...log })),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export interface UpdateCenterItem {
|
|||||||
source: UpdateSource;
|
source: UpdateSource;
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
nextVersion: string;
|
nextVersion: string;
|
||||||
|
arch?: string;
|
||||||
|
category?: string;
|
||||||
|
icon?: string;
|
||||||
ignored?: boolean;
|
ignored?: boolean;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
|
|||||||
134
src/__tests__/unit/update-center/UpdateCenterItem.test.ts
Normal file
134
src/__tests__/unit/update-center/UpdateCenterItem.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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 an icon image when item.icon exists", () => {
|
||||||
|
render(UpdateCenterItem, {
|
||||||
|
props: {
|
||||||
|
item: createItem({ icon: "/usr/share/pixmaps/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 a placeholder icon when the image fails", async () => {
|
||||||
|
render(UpdateCenterItem, {
|
||||||
|
props: {
|
||||||
|
item: createItem({ icon: "https://example.com/spark-weather.png" }),
|
||||||
|
task: createTask(),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||||
|
|
||||||
|
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("shows a new item icon again after a previous icon failure", async () => {
|
||||||
|
const { rerender } = render(UpdateCenterItem, {
|
||||||
|
props: {
|
||||||
|
item: createItem({ icon: "https://example.com/spark-weather.png" }),
|
||||||
|
task: createTask(),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||||
|
|
||||||
|
await fireEvent.error(firstIcon);
|
||||||
|
|
||||||
|
expect(firstIcon.getAttribute("src")).toContain("data:image/svg+xml");
|
||||||
|
|
||||||
|
await rerender({
|
||||||
|
item: createItem({
|
||||||
|
displayName: "Spark Clock",
|
||||||
|
icon: "/usr/share/pixmaps/spark-clock.png",
|
||||||
|
}),
|
||||||
|
task: createTask(),
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextIcon = screen.getByRole("img", { name: "Spark Clock 图标" });
|
||||||
|
|
||||||
|
expect(nextIcon).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"file:///usr/share/pixmaps/spark-clock.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries the same icon string when a fresh item object is rendered", async () => {
|
||||||
|
const brokenIcon = "https://example.com/spark-weather.png";
|
||||||
|
const { rerender } = render(UpdateCenterItem, {
|
||||||
|
props: {
|
||||||
|
item: createItem({ icon: brokenIcon }),
|
||||||
|
task: createTask(),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||||
|
|
||||||
|
await fireEvent.error(firstIcon);
|
||||||
|
|
||||||
|
expect(firstIcon.getAttribute("src")).toContain("data:image/svg+xml");
|
||||||
|
|
||||||
|
await rerender({
|
||||||
|
item: createItem({
|
||||||
|
currentVersion: "1.1.0",
|
||||||
|
newVersion: "2.1.0",
|
||||||
|
icon: brokenIcon,
|
||||||
|
}),
|
||||||
|
task: createTask({ progress: 75 }),
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const retriedIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
|
||||||
|
|
||||||
|
expect(retriedIcon).toHaveAttribute("src", brokenIcon);
|
||||||
|
});
|
||||||
|
});
|
||||||
254
src/__tests__/unit/update-center/icons.test.ts
Normal file
254
src/__tests__/unit/update-center/icons.test.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type FsState = {
|
||||||
|
directories?: Record<string, string[]>;
|
||||||
|
files?: Record<string, string>;
|
||||||
|
packageFiles?: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadIconsModule = async (state: FsState) => {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
vi.doMock("node:fs", () => {
|
||||||
|
const directories = state.directories ?? {};
|
||||||
|
const files = state.files ?? {};
|
||||||
|
|
||||||
|
const existsSync = (targetPath: string): boolean => {
|
||||||
|
return targetPath in directories || targetPath in files;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readdirSync = (targetPath: string): string[] => {
|
||||||
|
return directories[targetPath] ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFileSync = (targetPath: string): string => {
|
||||||
|
const content = files[targetPath];
|
||||||
|
if (content === undefined) {
|
||||||
|
throw new Error(`Unexpected read for ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
},
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock("node:child_process", () => {
|
||||||
|
const packageFiles = state.packageFiles ?? {};
|
||||||
|
|
||||||
|
const spawnSync = (_command: string, args: string[]) => {
|
||||||
|
const operation = args[0] ?? "";
|
||||||
|
const pkgname = args[1] ?? "";
|
||||||
|
const ownedFiles = packageFiles[pkgname];
|
||||||
|
if (operation !== "-L" || !ownedFiles) {
|
||||||
|
return {
|
||||||
|
status: 1,
|
||||||
|
error: undefined,
|
||||||
|
output: null,
|
||||||
|
pid: 0,
|
||||||
|
signal: null,
|
||||||
|
stdout: Buffer.alloc(0),
|
||||||
|
stderr: Buffer.alloc(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
error: undefined,
|
||||||
|
output: null,
|
||||||
|
pid: 0,
|
||||||
|
signal: null,
|
||||||
|
stdout: Buffer.from(`${ownedFiles.join("\n")}\n`),
|
||||||
|
stderr: Buffer.alloc(0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: { spawnSync },
|
||||||
|
spawnSync,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return await import("../../../../electron/main/backend/update-center/icons");
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.doUnmock("node:fs");
|
||||||
|
vi.doUnmock("node:child_process");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update-center icons", () => {
|
||||||
|
it("prefers local desktop icon paths for aptss items", async () => {
|
||||||
|
const pkgname = "spark-weather";
|
||||||
|
const applicationsDirectory = "/usr/share/applications";
|
||||||
|
const desktopPath = `${applicationsDirectory}/weather-launcher.desktop`;
|
||||||
|
const iconPath = `/usr/share/pixmaps/${pkgname}.png`;
|
||||||
|
const { resolveUpdateItemIcon } = await loadIconsModule({
|
||||||
|
directories: {
|
||||||
|
[applicationsDirectory]: ["weather-launcher.desktop"],
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
[desktopPath]: `[Desktop Entry]\nName=Spark Weather\nIcon=${iconPath}\n`,
|
||||||
|
[iconPath]: "png",
|
||||||
|
},
|
||||||
|
packageFiles: {
|
||||||
|
[pkgname]: [desktopPath],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveUpdateItemIcon({
|
||||||
|
pkgname,
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
}),
|
||||||
|
).toBe(iconPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves APM icon names from entries/icons when desktop icon is not absolute", async () => {
|
||||||
|
const pkgname = "spark-music";
|
||||||
|
const desktopDirectory = `/var/lib/apm/apm/files/ace-env/var/lib/apm/${pkgname}/entries/applications`;
|
||||||
|
const desktopPath = `${desktopDirectory}/${pkgname}.desktop`;
|
||||||
|
const iconPath = `/var/lib/apm/apm/files/ace-env/var/lib/apm/${pkgname}/entries/icons/hicolor/48x48/apps/${pkgname}.png`;
|
||||||
|
const { resolveUpdateItemIcon } = await loadIconsModule({
|
||||||
|
directories: {
|
||||||
|
[desktopDirectory]: [`${pkgname}.desktop`],
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
[desktopPath]: `[Desktop Entry]\nName=Spark Music\nIcon=${pkgname}\n`,
|
||||||
|
[iconPath]: "png",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveUpdateItemIcon({
|
||||||
|
pkgname,
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
}),
|
||||||
|
).toBe(iconPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks later APM desktop entries when the first one has no usable icon", async () => {
|
||||||
|
const pkgname = "spark-player";
|
||||||
|
const desktopDirectory = `/var/lib/apm/apm/files/ace-env/var/lib/apm/${pkgname}/entries/applications`;
|
||||||
|
const invalidDesktopPath = `${desktopDirectory}/invalid.desktop`;
|
||||||
|
const validDesktopPath = `${desktopDirectory}/valid.desktop`;
|
||||||
|
const iconPath = `/var/lib/apm/apm/files/ace-env/var/lib/apm/${pkgname}/entries/icons/hicolor/48x48/apps/${pkgname}.png`;
|
||||||
|
const { resolveApmIcon } = await loadIconsModule({
|
||||||
|
directories: {
|
||||||
|
[desktopDirectory]: ["invalid.desktop", "valid.desktop"],
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
[invalidDesktopPath]:
|
||||||
|
"[Desktop Entry]\nName=Invalid\nIcon=missing-icon\n",
|
||||||
|
[validDesktopPath]: `[Desktop Entry]\nName=Spark Player\nIcon=${pkgname}\n`,
|
||||||
|
[iconPath]: "png",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveApmIcon(pkgname)).toBe(iconPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves APM icons from installed /opt/apps entries when package-path assets are absent", async () => {
|
||||||
|
const pkgname = "spark-video";
|
||||||
|
const installedDesktopDirectory = `/opt/apps/${pkgname}/entries/applications`;
|
||||||
|
const installedDesktopPath = `${installedDesktopDirectory}/${pkgname}.desktop`;
|
||||||
|
const installedIconPath = `/opt/apps/${pkgname}/entries/icons/hicolor/48x48/apps/${pkgname}.png`;
|
||||||
|
const { resolveApmIcon } = await loadIconsModule({
|
||||||
|
directories: {
|
||||||
|
[installedDesktopDirectory]: [`${pkgname}.desktop`],
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
[installedDesktopPath]: `[Desktop Entry]\nName=Spark Video\nIcon=${pkgname}\n`,
|
||||||
|
[installedIconPath]: "png",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveApmIcon(pkgname)).toBe(installedIconPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves APM named icons from shared theme locations when local entries icons are absent", async () => {
|
||||||
|
const pkgname = "spark-camera";
|
||||||
|
const desktopDirectory = `/var/lib/apm/apm/files/ace-env/var/lib/apm/${pkgname}/entries/applications`;
|
||||||
|
const desktopPath = `${desktopDirectory}/${pkgname}.desktop`;
|
||||||
|
const sharedIconPath = `/usr/share/icons/hicolor/48x48/apps/${pkgname}.png`;
|
||||||
|
const { resolveApmIcon } = await loadIconsModule({
|
||||||
|
directories: {
|
||||||
|
[desktopDirectory]: [`${pkgname}.desktop`],
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
[desktopPath]: `[Desktop Entry]\nName=Spark Camera\nIcon=${pkgname}\n`,
|
||||||
|
[sharedIconPath]: "png",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveApmIcon(pkgname)).toBe(sharedIconPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a remote fallback URL when category and arch are available", async () => {
|
||||||
|
const { resolveUpdateItemIcon } = await loadIconsModule({});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveUpdateItemIcon({
|
||||||
|
pkgname: "spark-clock",
|
||||||
|
source: "apm",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
category: "utility",
|
||||||
|
arch: "amd64",
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
"https://erotica.spark-app.store/amd64-apm/utility/spark-clock/icon.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string when neither local nor remote icon can be determined", async () => {
|
||||||
|
const { resolveUpdateItemIcon } = await loadIconsModule({});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveUpdateItemIcon({
|
||||||
|
pkgname: "spark-empty",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
}),
|
||||||
|
).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated desktop files while still resolving owned non-exact filenames", async () => {
|
||||||
|
const pkgname = "spark-reader";
|
||||||
|
const applicationsDirectory = "/usr/share/applications";
|
||||||
|
const unrelatedDesktopPath = `${applicationsDirectory}/notes.desktop`;
|
||||||
|
const ownedDesktopPath = `${applicationsDirectory}/reader-launcher.desktop`;
|
||||||
|
const unrelatedIconPath = "/usr/share/pixmaps/notes.png";
|
||||||
|
const ownedIconPath = `/usr/share/pixmaps/${pkgname}.png`;
|
||||||
|
const { resolveDesktopIcon } = await loadIconsModule({
|
||||||
|
directories: {
|
||||||
|
[applicationsDirectory]: ["notes.desktop", "reader-launcher.desktop"],
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
[unrelatedDesktopPath]: `[Desktop Entry]\nName=Notes\nIcon=${unrelatedIconPath}\n`,
|
||||||
|
[ownedDesktopPath]: `[Desktop Entry]\nName=Spark Reader\nIcon=${ownedIconPath}\n`,
|
||||||
|
[unrelatedIconPath]: "png",
|
||||||
|
[ownedIconPath]: "png",
|
||||||
|
},
|
||||||
|
packageFiles: {
|
||||||
|
[pkgname]: [ownedDesktopPath],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveDesktopIcon(pkgname)).toBe(ownedIconPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { loadUpdateCenterItems } from "../../../../electron/main/backend/update-center";
|
|
||||||
|
|
||||||
interface CommandResult {
|
interface CommandResult {
|
||||||
code: number;
|
code: number;
|
||||||
@@ -8,6 +6,10 @@ interface CommandResult {
|
|||||||
stderr: string;
|
stderr: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RemoteStoreResponse =
|
||||||
|
| Record<string, unknown>
|
||||||
|
| Array<Record<string, unknown>>;
|
||||||
|
|
||||||
const APTSS_LIST_UPGRADABLE_KEY =
|
const APTSS_LIST_UPGRADABLE_KEY =
|
||||||
"bash -lc env LANGUAGE=en_US /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf list --upgradable -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null -o APT::Get::List-Cleanup=0";
|
"bash -lc env LANGUAGE=en_US /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf list --upgradable -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null -o APT::Get::List-Cleanup=0";
|
||||||
|
|
||||||
@@ -17,8 +19,86 @@ const DPKG_QUERY_INSTALLED_KEY =
|
|||||||
const APM_PRINT_URIS_KEY =
|
const APM_PRINT_URIS_KEY =
|
||||||
"bash -lc amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download spark-weather --print-uris";
|
"bash -lc amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download spark-weather --print-uris";
|
||||||
|
|
||||||
|
const loadUpdateCenterModule = async (
|
||||||
|
remoteStore: Record<string, RemoteStoreResponse>,
|
||||||
|
) => {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
vi.doMock("node:fs", () => {
|
||||||
|
const existsSync = () => false;
|
||||||
|
const readdirSync = () => [] as string[];
|
||||||
|
const readFileSync = () => {
|
||||||
|
throw new Error("Unexpected icon file read");
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
},
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock("node:child_process", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("node:child_process")>(
|
||||||
|
"node:child_process",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
spawnSync: (command: string, args: string[]) => {
|
||||||
|
if (command === "dpkg" && args[0] === "-L") {
|
||||||
|
return {
|
||||||
|
status: 1,
|
||||||
|
error: undefined,
|
||||||
|
output: null,
|
||||||
|
pid: 0,
|
||||||
|
signal: null,
|
||||||
|
stdout: Buffer.alloc(0),
|
||||||
|
stderr: Buffer.alloc(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return actual.spawnSync(command, args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: string | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
const body = remoteStore[url];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: body !== undefined,
|
||||||
|
async json() {
|
||||||
|
if (body === undefined) {
|
||||||
|
throw new Error(`Unexpected fetch for ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return await import("../../../../electron/main/backend/update-center/index");
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.doUnmock("node:fs");
|
||||||
|
vi.doUnmock("node:child_process");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
describe("update-center load items", () => {
|
describe("update-center load items", () => {
|
||||||
it("enriches apm and migration items with download metadata needed by the runner", async () => {
|
it("enriches apm migration items with download metadata and remote fallback icons", async () => {
|
||||||
const commandResults = new Map<string, CommandResult>([
|
const commandResults = new Map<string, CommandResult>([
|
||||||
[
|
[
|
||||||
APTSS_LIST_UPGRADABLE_KEY,
|
APTSS_LIST_UPGRADABLE_KEY,
|
||||||
@@ -62,6 +142,20 @@ describe("update-center load items", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
const { loadUpdateCenterItems } = await loadUpdateCenterModule({
|
||||||
|
"https://erotica.spark-app.store/amd64-store/categories.json": {
|
||||||
|
tools: { zh: "Tools" },
|
||||||
|
},
|
||||||
|
"https://erotica.spark-app.store/amd64-store/tools/applist.json": [
|
||||||
|
{ Pkgname: "spark-weather" },
|
||||||
|
],
|
||||||
|
"https://erotica.spark-app.store/amd64-apm/categories.json": {
|
||||||
|
tools: { zh: "Tools" },
|
||||||
|
},
|
||||||
|
"https://erotica.spark-app.store/amd64-apm/tools/applist.json": [
|
||||||
|
{ Pkgname: "spark-weather" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const result = await loadUpdateCenterItems(async (command, args) => {
|
const result = await loadUpdateCenterItems(async (command, args) => {
|
||||||
const key = `${command} ${args.join(" ")}`;
|
const key = `${command} ${args.join(" ")}`;
|
||||||
@@ -79,6 +173,9 @@ describe("update-center load items", () => {
|
|||||||
source: "apm",
|
source: "apm",
|
||||||
currentVersion: "1.5.0",
|
currentVersion: "1.5.0",
|
||||||
nextVersion: "3.0.0",
|
nextVersion: "3.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
category: "tools",
|
||||||
|
icon: "https://erotica.spark-app.store/amd64-apm/tools/spark-weather/icon.png",
|
||||||
downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb",
|
downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb",
|
||||||
fileName: "spark-weather_3.0.0_amd64.deb",
|
fileName: "spark-weather_3.0.0_amd64.deb",
|
||||||
size: 123456,
|
size: 123456,
|
||||||
@@ -91,6 +188,15 @@ describe("update-center load items", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("degrades to aptss-only results when apm commands fail", async () => {
|
it("degrades to aptss-only results when apm commands fail", async () => {
|
||||||
|
const { loadUpdateCenterItems } = await loadUpdateCenterModule({
|
||||||
|
"https://erotica.spark-app.store/amd64-store/categories.json": {
|
||||||
|
office: { zh: "Office" },
|
||||||
|
},
|
||||||
|
"https://erotica.spark-app.store/amd64-store/office/applist.json": [
|
||||||
|
{ Pkgname: "spark-notes" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const result = await loadUpdateCenterItems(async (command, args) => {
|
const result = await loadUpdateCenterItems(async (command, args) => {
|
||||||
const key = `${command} ${args.join(" ")}`;
|
const key = `${command} ${args.join(" ")}`;
|
||||||
|
|
||||||
@@ -127,6 +233,136 @@ describe("update-center load items", () => {
|
|||||||
source: "aptss",
|
source: "aptss",
|
||||||
currentVersion: "1.0.0",
|
currentVersion: "1.0.0",
|
||||||
nextVersion: "2.0.0",
|
nextVersion: "2.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
category: "office",
|
||||||
|
icon: "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.warnings).toEqual([
|
||||||
|
"apm upgradable query failed: apm: command not found",
|
||||||
|
"apm installed query failed: apm: command not found",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries category lookup after an earlier fetch failure in the same process", async () => {
|
||||||
|
const remoteStore: Record<string, RemoteStoreResponse> = {};
|
||||||
|
const { loadUpdateCenterItems } = await loadUpdateCenterModule(remoteStore);
|
||||||
|
|
||||||
|
const runCommand = async (command: string, args: string[]) => {
|
||||||
|
const key = `${command} ${args.join(" ")}`;
|
||||||
|
|
||||||
|
if (key === APTSS_LIST_UPGRADABLE_KEY) {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
stdout: "spark-notes/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === DPKG_QUERY_INSTALLED_KEY) {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
stdout: "spark-notes\tinstall ok installed\n",
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "apm list --upgradable" || key === "apm list --installed") {
|
||||||
|
return {
|
||||||
|
code: 127,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "apm: command not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected command ${key}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstResult = await loadUpdateCenterItems(runCommand);
|
||||||
|
|
||||||
|
expect(firstResult.items).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-notes",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
remoteStore["https://erotica.spark-app.store/amd64-store/categories.json"] =
|
||||||
|
{
|
||||||
|
office: { zh: "Office" },
|
||||||
|
};
|
||||||
|
remoteStore[
|
||||||
|
"https://erotica.spark-app.store/amd64-store/office/applist.json"
|
||||||
|
] = [{ Pkgname: "spark-notes" }];
|
||||||
|
|
||||||
|
const secondResult = await loadUpdateCenterItems(runCommand);
|
||||||
|
|
||||||
|
expect(secondResult.items).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-notes",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
category: "office",
|
||||||
|
icon: "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps successfully loaded categories when another category applist fetch fails", async () => {
|
||||||
|
const { loadUpdateCenterItems } = await loadUpdateCenterModule({
|
||||||
|
"https://erotica.spark-app.store/amd64-store/categories.json": {
|
||||||
|
office: { zh: "Office" },
|
||||||
|
tools: { zh: "Tools" },
|
||||||
|
},
|
||||||
|
"https://erotica.spark-app.store/amd64-store/office/applist.json": [
|
||||||
|
{ Pkgname: "spark-notes" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await loadUpdateCenterItems(async (command, args) => {
|
||||||
|
const key = `${command} ${args.join(" ")}`;
|
||||||
|
|
||||||
|
if (key === APTSS_LIST_UPGRADABLE_KEY) {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
stdout: "spark-notes/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === DPKG_QUERY_INSTALLED_KEY) {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
stdout: "spark-notes\tinstall ok installed\n",
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "apm list --upgradable" || key === "apm list --installed") {
|
||||||
|
return {
|
||||||
|
code: 127,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "apm: command not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected command ${key}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-notes",
|
||||||
|
source: "aptss",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
nextVersion: "2.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
category: "office",
|
||||||
|
icon: "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(result.warnings).toEqual([
|
expect(result.warnings).toEqual([
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ describe("update-center query", () => {
|
|||||||
source: "aptss",
|
source: "aptss",
|
||||||
currentVersion: "1.9.0",
|
currentVersion: "1.9.0",
|
||||||
nextVersion: "2.0.0",
|
nextVersion: "2.0.0",
|
||||||
|
arch: "amd64",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -37,6 +38,7 @@ describe("update-center query", () => {
|
|||||||
source: "aptss",
|
source: "aptss",
|
||||||
currentVersion: "1.1.0",
|
currentVersion: "1.1.0",
|
||||||
nextVersion: "1.2.0",
|
nextVersion: "1.2.0",
|
||||||
|
arch: "amd64",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ describe("update-center query", () => {
|
|||||||
source: "apm",
|
source: "apm",
|
||||||
currentVersion: "1.5.0",
|
currentVersion: "1.5.0",
|
||||||
nextVersion: "2.0.0",
|
nextVersion: "2.0.0",
|
||||||
|
arch: "amd64",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -262,6 +265,7 @@ describe("update-center query", () => {
|
|||||||
source: "apm",
|
source: "apm",
|
||||||
currentVersion: "4.5.0",
|
currentVersion: "4.5.0",
|
||||||
nextVersion: "5.0.0",
|
nextVersion: "5.0.0",
|
||||||
|
arch: "arm64",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -217,6 +217,36 @@ describe("update-center/ipc", () => {
|
|||||||
expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion");
|
expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("service task snapshots keep item icons for queued work", async () => {
|
||||||
|
const service = createUpdateCenterService({
|
||||||
|
loadItems: async () => [{ ...createItem(), icon: "/icons/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",
|
||||||
|
icon: "/icons/weather.png",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("concurrent start calls still serialize through one processing pipeline", async () => {
|
it("concurrent start calls still serialize through one processing pipeline", async () => {
|
||||||
const startedTaskIds: number[] = [];
|
const startedTaskIds: number[] = [];
|
||||||
const releases: Array<() => void> = [];
|
const releases: Array<() => void> = [];
|
||||||
|
|||||||
@@ -10,6 +10,16 @@
|
|||||||
:disabled="item.ignored === true"
|
:disabled="item.ignored === true"
|
||||||
@change="$emit('toggle-selection')"
|
@change="$emit('toggle-selection')"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="iconSrc"
|
||||||
|
:alt="`${item.displayName} 图标`"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
@error="handleIconError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="font-semibold text-slate-900 dark:text-white">
|
<p class="font-semibold text-slate-900 dark:text-white">
|
||||||
@@ -67,7 +77,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
UpdateCenterItem,
|
UpdateCenterItem,
|
||||||
@@ -80,10 +90,39 @@ const props = defineProps<{
|
|||||||
selected: boolean;
|
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 failedIcon = ref<string | null>(null);
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
(e: "toggle-selection"): void;
|
(e: "toggle-selection"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const normalizeIconSrc = (icon?: string): string => {
|
||||||
|
if (!icon || failedIcon.value === icon) {
|
||||||
|
return PLACEHOLDER_ICON;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[a-z]+:\/\//i.test(icon)) {
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
return icon.startsWith("/") ? `file://${icon}` : icon;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIconError = () => {
|
||||||
|
failedIcon.value = props.item.icon ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.item,
|
||||||
|
() => {
|
||||||
|
failedIcon.value = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconSrc = computed(() => normalizeIconSrc(props.item.icon));
|
||||||
|
|
||||||
const sourceLabel = computed(() => {
|
const sourceLabel = computed(() => {
|
||||||
return props.item.source === "apm" ? "APM" : "传统deb";
|
return props.item.source === "apm" ? "APM" : "传统deb";
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export interface UpdateCenterItem {
|
|||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
newVersion: string;
|
newVersion: string;
|
||||||
source: UpdateSource;
|
source: UpdateSource;
|
||||||
|
icon?: string;
|
||||||
ignored?: boolean;
|
ignored?: boolean;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
@@ -155,6 +156,7 @@ export interface UpdateCenterTaskState {
|
|||||||
taskKey: string;
|
taskKey: string;
|
||||||
packageName: string;
|
packageName: string;
|
||||||
source: UpdateSource;
|
source: UpdateSource;
|
||||||
|
icon?: string;
|
||||||
status: UpdateCenterTaskStatus;
|
status: UpdateCenterTaskStatus;
|
||||||
progress: number;
|
progress: number;
|
||||||
logs: Array<{ time: number; message: string }>;
|
logs: Array<{ time: number; message: string }>;
|
||||||
|
|||||||
Reference in New Issue
Block a user