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..2b1e9ccc --- /dev/null +++ b/electron/main/backend/update-center/icons.ts @@ -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 => { + 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, + }) || "" + ); +}; diff --git a/electron/main/backend/update-center/index.ts b/electron/main/backend/update-center/index.ts index ed710883..34aeba08 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 { resolveUpdateItemIcon } 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 icon = resolveUpdateItemIcon(item); + + return icon ? { ...item, icon } : 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..2bb35183 100644 --- a/electron/main/backend/update-center/service.ts +++ b/electron/main/backend/update-center/service.ts @@ -25,6 +25,7 @@ export interface UpdateCenterServiceItem { currentVersion: string; newVersion: string; source: UpdateSource; + icon?: string; ignored?: boolean; downloadUrl?: string; fileName?: string; @@ -40,6 +41,7 @@ export interface UpdateCenterServiceTask { taskKey: string; packageName: string; source: UpdateSource; + icon?: string; status: UpdateCenterQueueSnapshot["tasks"][number]["status"]; progress: number; logs: UpdateCenterQueueSnapshot["tasks"][number]["logs"]; @@ -96,6 +98,7 @@ const toState = ( currentVersion: item.currentVersion, newVersion: item.nextVersion, source: item.source, + icon: item.icon, ignored: item.ignored, downloadUrl: item.downloadUrl, fileName: item.fileName, @@ -110,6 +113,7 @@ const toState = ( taskKey: getTaskKey(task.item), packageName: task.pkgname, source: task.item.source, + icon: task.item.icon, 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..0019a06f 100644 --- a/electron/main/backend/update-center/types.ts +++ b/electron/main/backend/update-center/types.ts @@ -10,6 +10,9 @@ export interface UpdateCenterItem { source: UpdateSource; currentVersion: string; nextVersion: string; + arch?: string; + category?: string; + icon?: string; ignored?: boolean; downloadUrl?: string; fileName?: string; 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..8d98ff77 --- /dev/null +++ b/src/__tests__/unit/update-center/UpdateCenterItem.test.ts @@ -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 => ({ + 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 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); + }); +}); 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..d81db7cd --- /dev/null +++ b/src/__tests__/unit/update-center/icons.test.ts @@ -0,0 +1,254 @@ +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("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); + }); +}); diff --git a/src/__tests__/unit/update-center/load-items.test.ts b/src/__tests__/unit/update-center/load-items.test.ts index adab1cc7..ba405796 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,9 @@ describe("update-center load items", () => { source: "apm", currentVersion: "1.5.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", fileName: "spark-weather_3.0.0_amd64.deb", size: 123456, @@ -91,6 +188,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 +233,136 @@ describe("update-center load items", () => { 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([ + "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", + 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([ 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..76c11b26 100644 --- a/src/__tests__/unit/update-center/registerUpdateCenter.test.ts +++ b/src/__tests__/unit/update-center/registerUpdateCenter.test.ts @@ -217,6 +217,36 @@ describe("update-center/ipc", () => { 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 () => { 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..00346f7c 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')" /> +
+ +

@@ -67,7 +77,7 @@