diff --git a/docs/superpowers/plans/2026-04-10-update-center-icons.md b/docs/superpowers/plans/2026-04-10-update-center-icons.md
new file mode 100644
index 00000000..94924b12
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-10-update-center-icons.md
@@ -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 `
+```
+
+- [ ] **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"
+```
diff --git a/docs/superpowers/specs/2026-04-10-update-center-icons-design.md b/docs/superpowers/specs/2026-04-10-update-center-icons-design.md
new file mode 100644
index 00000000..69196d8d
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-10-update-center-icons-design.md
@@ -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 仅新增稳定图标位,不改变现有更新列表信息层级。
diff --git a/electron/main/backend/update-center/icons.ts b/electron/main/backend/update-center/icons.ts
new file mode 100644
index 00000000..41e846d9
--- /dev/null
+++ b/electron/main/backend/update-center/icons.ts
@@ -0,0 +1,211 @@
+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 => {
+ 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 resolveUpdateItemIcons = (
+ item: UpdateCenterItem,
+): Pick => {
+ 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,
+ });
+
+ if (localIcon && remoteIcon) {
+ return { localIcon, remoteIcon };
+ }
+
+ if (localIcon) {
+ return { localIcon };
+ }
+
+ if (remoteIcon) {
+ return { remoteIcon };
+ }
+
+ return {};
+};
diff --git a/electron/main/backend/update-center/index.ts b/electron/main/backend/update-center/index.ts
index ed710883..f5de499f 100644
--- a/electron/main/backend/update-center/index.ts
+++ b/electron/main/backend/update-center/index.ts
@@ -9,6 +9,7 @@ import {
parseAptssUpgradableOutput,
parsePrintUrisOutput,
} from "./query";
+import { resolveUpdateItemIcons } from "./icons";
import {
createUpdateCenterService,
type UpdateCenterIgnorePayload,
@@ -32,6 +33,15 @@ export interface UpdateCenterLoadItemsResult {
warnings: string[];
}
+type StoreCategoryMap = Map;
+
+interface RemoteCategoryAppEntry {
+ Pkgname?: string;
+}
+
+const REMOTE_STORE_BASE_URL = "https://erotica.spark-app.store";
+const categoryCache = new Map>();
+
const APTSS_LIST_UPGRADABLE_COMMAND = {
command: "bash",
args: [
@@ -146,6 +156,105 @@ const enrichApmItems = async (
};
};
+const getStoreArch = (
+ item: Pick,
+): string => {
+ const arch = item.arch;
+ if (!arch) {
+ return "";
+ }
+
+ if (arch.includes("-")) {
+ return arch;
+ }
+
+ return `${arch}-${item.source === "aptss" ? "store" : "apm"}`;
+};
+
+const loadJson = async (url: string): Promise => {
+ 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 => {
+ const categories = await loadJson>(
+ `${REMOTE_STORE_BASE_URL}/${storeArch}/categories.json`,
+ );
+ const categoryEntries = await Promise.allSettled(
+ Object.keys(categories).map(async (category) => {
+ const apps = await loadJson(
+ `${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 => {
+ 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 => {
+ 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 icons = resolveUpdateItemIcons(item);
+
+ return Object.keys(icons).length > 0 ? { ...item, ...icons } : item;
+ });
+};
+
export const loadUpdateCenterItems = async (
runCommand: UpdateCenterCommandRunner = runCommandCapture,
): Promise => {
@@ -186,12 +295,19 @@ export const loadUpdateCenterItems = async (
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 {
items: mergeUpdateSources(
- aptssItems,
- enrichedApmItems.items,
+ enrichItemIcons(categorizedAptssItems),
+ enrichItemIcons(enrichedApmItems.items),
installedSources,
),
warnings: [...warnings, ...enrichedApmItems.warnings],
diff --git a/electron/main/backend/update-center/query.ts b/electron/main/backend/update-center/query.ts
index 40bca89f..74ce1e4c 100644
--- a/electron/main/backend/update-center/query.ts
+++ b/electron/main/backend/update-center/query.ts
@@ -201,6 +201,7 @@ const parseUpgradableOutput = (
}
const [, pkgname, nextVersion, currentVersion] = match;
+ const arch = trimmed.split(/\s+/)[2];
if (!pkgname || nextVersion === currentVersion) {
continue;
}
@@ -210,6 +211,7 @@ const parseUpgradableOutput = (
source,
currentVersion,
nextVersion,
+ arch,
});
}
diff --git a/electron/main/backend/update-center/service.ts b/electron/main/backend/update-center/service.ts
index 68b4a198..f6fc2213 100644
--- a/electron/main/backend/update-center/service.ts
+++ b/electron/main/backend/update-center/service.ts
@@ -25,6 +25,8 @@ export interface UpdateCenterServiceItem {
currentVersion: string;
newVersion: string;
source: UpdateSource;
+ localIcon?: string;
+ remoteIcon?: string;
ignored?: boolean;
downloadUrl?: string;
fileName?: string;
@@ -40,6 +42,8 @@ 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"];
@@ -96,6 +100,8 @@ const toState = (
currentVersion: item.currentVersion,
newVersion: item.nextVersion,
source: item.source,
+ localIcon: item.localIcon,
+ remoteIcon: item.remoteIcon,
ignored: item.ignored,
downloadUrl: item.downloadUrl,
fileName: item.fileName,
@@ -110,6 +116,8 @@ const toState = (
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 })),
diff --git a/electron/main/backend/update-center/types.ts b/electron/main/backend/update-center/types.ts
index e70eee63..e442a85c 100644
--- a/electron/main/backend/update-center/types.ts
+++ b/electron/main/backend/update-center/types.ts
@@ -10,6 +10,10 @@ export interface UpdateCenterItem {
source: UpdateSource;
currentVersion: string;
nextVersion: string;
+ arch?: string;
+ category?: string;
+ localIcon?: string;
+ remoteIcon?: string;
ignored?: boolean;
downloadUrl?: string;
fileName?: string;
diff --git a/electron/preload/index.ts b/electron/preload/index.ts
index 2560fa61..9b663c41 100644
--- a/electron/preload/index.ts
+++ b/electron/preload/index.ts
@@ -8,12 +8,16 @@ type UpdateCenterSnapshot = {
currentVersion: string;
newVersion: string;
source: "aptss" | "apm";
+ localIcon?: string;
+ remoteIcon?: string;
ignored?: boolean;
}>;
tasks: Array<{
taskKey: string;
packageName: string;
source: "aptss" | "apm";
+ localIcon?: string;
+ remoteIcon?: string;
status:
| "queued"
| "downloading"
diff --git a/src/__tests__/unit/update-center/UpdateCenterItem.test.ts b/src/__tests__/unit/update-center/UpdateCenterItem.test.ts
new file mode 100644
index 00000000..4848ddae
--- /dev/null
+++ b/src/__tests__/unit/update-center/UpdateCenterItem.test.ts
@@ -0,0 +1,217 @@
+import { fireEvent, render, screen } from "@testing-library/vue";
+import { describe, expect, it } from "vitest";
+import { defineComponent, nextTick, reactive, ref } from "vue";
+
+import UpdateCenterItem from "@/components/update-center/UpdateCenterItem.vue";
+import type {
+ UpdateCenterItem as UpdateCenterItemData,
+ UpdateCenterTaskState,
+} from "@/global/typedefinition";
+
+const createItem = (
+ overrides: Partial = {},
+): 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 => ({
+ 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({
+ displayName: "Spark Clock",
+ localIcon: "/usr/share/pixmaps/spark-clock.png",
+ remoteIcon: "https://example.com/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("restarts from localIcon when icon sources change on the same item object", async () => {
+ const item = reactive(
+ createItem({
+ localIcon: "/usr/share/pixmaps/spark-weather.png",
+ remoteIcon: "https://example.com/spark-weather.png",
+ }),
+ );
+ render(UpdateCenterItem, {
+ props: {
+ item,
+ task: createTask(),
+ selected: false,
+ },
+ });
+
+ const firstIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
+
+ await fireEvent.error(firstIcon);
+ await fireEvent.error(firstIcon);
+
+ expect(firstIcon.getAttribute("src")).toContain("data:image/svg+xml");
+
+ item.localIcon = "/usr/share/pixmaps/spark-weather-refreshed.png";
+ item.remoteIcon = "https://example.com/spark-weather-refreshed.png";
+
+ await nextTick();
+
+ const retriedIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
+
+ expect(retriedIcon).toHaveAttribute(
+ "src",
+ "file:///usr/share/pixmaps/spark-weather-refreshed.png",
+ );
+ });
+
+ it("restarts from localIcon for a fresh item object with the same icon sources", async () => {
+ const item = ref(
+ createItem({
+ localIcon: "/usr/share/pixmaps/spark-weather.png",
+ remoteIcon: "https://example.com/spark-weather.png",
+ }),
+ );
+ const Wrapper = defineComponent({
+ components: { UpdateCenterItem },
+ setup() {
+ return {
+ item,
+ task: createTask(),
+ };
+ },
+ template:
+ '',
+ });
+
+ render(Wrapper);
+
+ const firstIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
+
+ await fireEvent.error(firstIcon);
+ await fireEvent.error(firstIcon);
+
+ expect(firstIcon.getAttribute("src")).toContain("data:image/svg+xml");
+
+ item.value = createItem({
+ localIcon: "/usr/share/pixmaps/spark-weather.png",
+ remoteIcon: "https://example.com/spark-weather.png",
+ });
+ await nextTick();
+
+ const retriedIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
+
+ expect(retriedIcon).toHaveAttribute(
+ "src",
+ "file:///usr/share/pixmaps/spark-weather.png",
+ );
+ });
+});
diff --git a/src/__tests__/unit/update-center/icons.test.ts b/src/__tests__/unit/update-center/icons.test.ts
new file mode 100644
index 00000000..612307f9
--- /dev/null
+++ b/src/__tests__/unit/update-center/icons.test.ts
@@ -0,0 +1,289 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+type FsState = {
+ directories?: Record;
+ files?: Record;
+ packageFiles?: Record;
+};
+
+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("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("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 { resolveUpdateItemIcons } = await loadIconsModule({
+ directories: {
+ [desktopDirectory]: [`${pkgname}.desktop`],
+ },
+ files: {
+ [desktopPath]: `[Desktop Entry]\nName=Spark Music\nIcon=${pkgname}\n`,
+ [iconPath]: "png",
+ },
+ });
+
+ expect(
+ resolveUpdateItemIcons({
+ pkgname,
+ source: "apm",
+ currentVersion: "1.0.0",
+ nextVersion: "2.0.0",
+ }),
+ ).toEqual({ localIcon: 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("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({});
+ });
+
+ 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);
+ });
+});
diff --git a/src/__tests__/unit/update-center/load-items.test.ts b/src/__tests__/unit/update-center/load-items.test.ts
index adab1cc7..c2876304 100644
--- a/src/__tests__/unit/update-center/load-items.test.ts
+++ b/src/__tests__/unit/update-center/load-items.test.ts
@@ -1,6 +1,4 @@
-import { describe, expect, it } from "vitest";
-
-import { loadUpdateCenterItems } from "../../../../electron/main/backend/update-center";
+import { afterEach, describe, expect, it, vi } from "vitest";
interface CommandResult {
code: number;
@@ -8,6 +6,10 @@ interface CommandResult {
stderr: string;
}
+type RemoteStoreResponse =
+ | Record
+ | Array>;
+
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";
@@ -17,8 +19,86 @@ const DPKG_QUERY_INSTALLED_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";
+const loadUpdateCenterModule = async (
+ remoteStore: Record,
+) => {
+ 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(
+ "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", () => {
- 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([
[
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 key = `${command} ${args.join(" ")}`;
@@ -79,6 +173,10 @@ describe("update-center load items", () => {
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,
@@ -91,6 +189,15 @@ describe("update-center load items", () => {
});
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 key = `${command} ${args.join(" ")}`;
@@ -127,6 +234,139 @@ describe("update-center load items", () => {
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",
+ },
+ ]);
+ 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 = {};
+ 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",
+ remoteIcon:
+ "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",
+ remoteIcon:
+ "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
},
]);
expect(result.warnings).toEqual([
diff --git a/src/__tests__/unit/update-center/query.test.ts b/src/__tests__/unit/update-center/query.test.ts
index 130bc9fe..027c3d96 100644
--- a/src/__tests__/unit/update-center/query.test.ts
+++ b/src/__tests__/unit/update-center/query.test.ts
@@ -23,6 +23,7 @@ describe("update-center query", () => {
source: "aptss",
currentVersion: "1.9.0",
nextVersion: "2.0.0",
+ arch: "amd64",
},
]);
});
@@ -37,6 +38,7 @@ describe("update-center query", () => {
source: "aptss",
currentVersion: "1.1.0",
nextVersion: "1.2.0",
+ arch: "amd64",
},
]);
@@ -46,6 +48,7 @@ describe("update-center query", () => {
source: "apm",
currentVersion: "1.5.0",
nextVersion: "2.0.0",
+ arch: "amd64",
},
]);
});
@@ -262,6 +265,7 @@ describe("update-center query", () => {
source: "apm",
currentVersion: "4.5.0",
nextVersion: "5.0.0",
+ arch: "arm64",
},
]);
});
diff --git a/src/__tests__/unit/update-center/registerUpdateCenter.test.ts b/src/__tests__/unit/update-center/registerUpdateCenter.test.ts
index 7766a753..f932ec8b 100644
--- a/src/__tests__/unit/update-center/registerUpdateCenter.test.ts
+++ b/src/__tests__/unit/update-center/registerUpdateCenter.test.ts
@@ -217,6 +217,51 @@ describe("update-center/ipc", () => {
expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion");
});
+ it("service task snapshots keep localIcon and remoteIcon for queued work", async () => {
+ let releaseTask: (() => void) | undefined;
+ 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;
+ }
+
+ await new Promise((resolve) => {
+ releaseTask = resolve;
+ });
+ queue.markActiveTask(task.id, "installing");
+ queue.finishTask(task.id, "completed");
+ return task;
+ },
+ }),
+ });
+
+ await service.refresh();
+ const startPromise = service.start(["aptss:spark-weather"]);
+ await flushPromises();
+
+ expect(service.getState().tasks).toMatchObject([
+ {
+ taskKey: "aptss:spark-weather",
+ localIcon: "/icons/weather.png",
+ remoteIcon: "https://example.com/weather.png",
+ status: "queued",
+ },
+ ]);
+
+ releaseTask?.();
+ await startPromise;
+ });
+
it("concurrent start calls still serialize through one processing pipeline", async () => {
const startedTaskIds: number[] = [];
const releases: Array<() => void> = [];
diff --git a/src/components/update-center/UpdateCenterItem.vue b/src/components/update-center/UpdateCenterItem.vue
index da64cff2..be5decb0 100644
--- a/src/components/update-center/UpdateCenterItem.vue
+++ b/src/components/update-center/UpdateCenterItem.vue
@@ -10,6 +10,16 @@
:disabled="item.ignored === true"
@change="$emit('toggle-selection')"
/>
+
+
![]()
+