From 180b88b5c0f3fbcd2a5ffbda38aefe77214927ea Mon Sep 17 00:00:00 2001 From: momen Date: Sat, 11 Apr 2026 11:41:01 +0800 Subject: [PATCH] fix(update-center): cascade local and remote icon fallbacks Keep update list icons from dropping straight to placeholders by retrying the remote store icon after local load failures. Align the update-center IPC and renderer types with the split local/remote icon contract. --- electron/main/backend/update-center/icons.ts | 32 +++-- electron/main/backend/update-center/index.ts | 6 +- .../main/backend/update-center/service.ts | 12 +- electron/main/backend/update-center/types.ts | 3 +- electron/preload/index.ts | 4 + .../update-center/UpdateCenterItem.test.ts | 127 +++++++++++++++--- .../unit/update-center/icons.test.ts | 69 +++++++--- .../unit/update-center/load-items.test.ts | 12 +- .../registerUpdateCenter.test.ts | 25 +++- .../update-center/UpdateCenterItem.vue | 28 ++-- src/global/typedefinition.ts | 6 +- 11 files changed, 245 insertions(+), 79 deletions(-) diff --git a/electron/main/backend/update-center/icons.ts b/electron/main/backend/update-center/icons.ts index 2b1e9ccc..41e846d9 100644 --- a/electron/main/backend/update-center/icons.ts +++ b/electron/main/backend/update-center/icons.ts @@ -181,21 +181,31 @@ export const buildRemoteFallbackIconUrl = ({ return `${baseUrl}/${storeArch}/${category}/${pkgname}/icon.png`; }; -export const resolveUpdateItemIcon = (item: UpdateCenterItem): string => { +export const resolveUpdateItemIcons = ( + item: UpdateCenterItem, +): Pick => { const localIcon = item.source === "aptss" ? resolveDesktopIcon(item.pkgname) : resolveApmIcon(item.pkgname); - if (localIcon) { - return localIcon; + const remoteIcon = buildRemoteFallbackIconUrl({ + pkgname: item.pkgname, + source: item.source, + arch: item.arch, + category: item.category, + }); + + if (localIcon && remoteIcon) { + return { localIcon, remoteIcon }; } - return ( - buildRemoteFallbackIconUrl({ - pkgname: item.pkgname, - source: item.source, - arch: item.arch, - category: item.category, - }) || "" - ); + 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 34aeba08..f5de499f 100644 --- a/electron/main/backend/update-center/index.ts +++ b/electron/main/backend/update-center/index.ts @@ -9,7 +9,7 @@ import { parseAptssUpgradableOutput, parsePrintUrisOutput, } from "./query"; -import { resolveUpdateItemIcon } from "./icons"; +import { resolveUpdateItemIcons } from "./icons"; import { createUpdateCenterService, type UpdateCenterIgnorePayload, @@ -249,9 +249,9 @@ const enrichItemCategories = async ( const enrichItemIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => { return items.map((item) => { - const icon = resolveUpdateItemIcon(item); + const icons = resolveUpdateItemIcons(item); - return icon ? { ...item, icon } : item; + return Object.keys(icons).length > 0 ? { ...item, ...icons } : item; }); }; diff --git a/electron/main/backend/update-center/service.ts b/electron/main/backend/update-center/service.ts index 2bb35183..f6fc2213 100644 --- a/electron/main/backend/update-center/service.ts +++ b/electron/main/backend/update-center/service.ts @@ -25,7 +25,8 @@ export interface UpdateCenterServiceItem { currentVersion: string; newVersion: string; source: UpdateSource; - icon?: string; + localIcon?: string; + remoteIcon?: string; ignored?: boolean; downloadUrl?: string; fileName?: string; @@ -41,7 +42,8 @@ export interface UpdateCenterServiceTask { taskKey: string; packageName: string; source: UpdateSource; - icon?: string; + localIcon?: string; + remoteIcon?: string; status: UpdateCenterQueueSnapshot["tasks"][number]["status"]; progress: number; logs: UpdateCenterQueueSnapshot["tasks"][number]["logs"]; @@ -98,7 +100,8 @@ const toState = ( currentVersion: item.currentVersion, newVersion: item.nextVersion, source: item.source, - icon: item.icon, + localIcon: item.localIcon, + remoteIcon: item.remoteIcon, ignored: item.ignored, downloadUrl: item.downloadUrl, fileName: item.fileName, @@ -113,7 +116,8 @@ const toState = ( taskKey: getTaskKey(task.item), packageName: task.pkgname, source: task.item.source, - icon: task.item.icon, + 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 0019a06f..e442a85c 100644 --- a/electron/main/backend/update-center/types.ts +++ b/electron/main/backend/update-center/types.ts @@ -12,7 +12,8 @@ export interface UpdateCenterItem { nextVersion: string; arch?: string; category?: string; - icon?: 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 index 8d98ff77..4848ddae 100644 --- a/src/__tests__/unit/update-center/UpdateCenterItem.test.ts +++ b/src/__tests__/unit/update-center/UpdateCenterItem.test.ts @@ -1,5 +1,6 @@ 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 { @@ -33,10 +34,13 @@ const createTask = ( }); describe("UpdateCenterItem", () => { - it("renders an icon image when item.icon exists", () => { + it("renders localIcon first when both icon sources exist", () => { render(UpdateCenterItem, { props: { - item: createItem({ icon: "/usr/share/pixmaps/spark-weather.png" }), + item: createItem({ + localIcon: "/usr/share/pixmaps/spark-weather.png", + remoteIcon: "https://example.com/spark-weather.png", + }), task: createTask(), selected: false, }, @@ -50,10 +54,13 @@ describe("UpdateCenterItem", () => { ); }); - it("falls back to a placeholder icon when the image fails", async () => { + it("falls back to remoteIcon when localIcon fails", async () => { render(UpdateCenterItem, { props: { - item: createItem({ icon: "https://example.com/spark-weather.png" }), + item: createItem({ + localIcon: "/usr/share/pixmaps/spark-weather.png", + remoteIcon: "https://example.com/spark-weather.png", + }), task: createTask(), selected: false, }, @@ -63,16 +70,42 @@ describe("UpdateCenterItem", () => { 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("shows a new item icon again after a previous icon failure", async () => { + it("restarts from localIcon when a new item is rendered", async () => { const { rerender } = render(UpdateCenterItem, { props: { - item: createItem({ icon: "https://example.com/spark-weather.png" }), + item: createItem({ + localIcon: "/usr/share/pixmaps/spark-weather.png", + remoteIcon: "https://example.com/spark-weather.png", + }), task: createTask(), selected: false, }, @@ -82,12 +115,16 @@ describe("UpdateCenterItem", () => { await fireEvent.error(firstIcon); - expect(firstIcon.getAttribute("src")).toContain("data:image/svg+xml"); + expect(firstIcon).toHaveAttribute( + "src", + "https://example.com/spark-weather.png", + ); await rerender({ item: createItem({ displayName: "Spark Clock", - icon: "/usr/share/pixmaps/spark-clock.png", + localIcon: "/usr/share/pixmaps/spark-clock.png", + remoteIcon: "https://example.com/spark-clock.png", }), task: createTask(), selected: false, @@ -101,11 +138,16 @@ describe("UpdateCenterItem", () => { ); }); - 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, { + 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: createItem({ icon: brokenIcon }), + item, task: createTask(), selected: false, }, @@ -113,22 +155,63 @@ describe("UpdateCenterItem", () => { 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"); - await rerender({ - item: createItem({ - currentVersion: "1.1.0", - newVersion: "2.1.0", - icon: brokenIcon, - }), - task: createTask({ progress: 75 }), - selected: false, - }); + 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", brokenIcon); + 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 index d81db7cd..612307f9 100644 --- a/src/__tests__/unit/update-center/icons.test.ts +++ b/src/__tests__/unit/update-center/icons.test.ts @@ -87,12 +87,12 @@ afterEach(() => { }); describe("update-center icons", () => { - it("prefers local desktop icon paths for aptss items", async () => { + 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 { resolveUpdateItemIcon } = await loadIconsModule({ + const { resolveUpdateItemIcons } = await loadIconsModule({ directories: { [applicationsDirectory]: ["weather-launcher.desktop"], }, @@ -106,13 +106,19 @@ describe("update-center icons", () => { }); expect( - resolveUpdateItemIcon({ + resolveUpdateItemIcons({ pkgname, source: "aptss", currentVersion: "1.0.0", nextVersion: "2.0.0", + category: "tools", + arch: "amd64", }), - ).toBe(iconPath); + ).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 () => { @@ -120,7 +126,7 @@ describe("update-center icons", () => { 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({ + const { resolveUpdateItemIcons } = await loadIconsModule({ directories: { [desktopDirectory]: [`${pkgname}.desktop`], }, @@ -131,13 +137,13 @@ describe("update-center icons", () => { }); expect( - resolveUpdateItemIcon({ + resolveUpdateItemIcons({ pkgname, source: "apm", currentVersion: "1.0.0", nextVersion: "2.0.0", }), - ).toBe(iconPath); + ).toEqual({ localIcon: iconPath }); }); it("checks later APM desktop entries when the first one has no usable icon", async () => { @@ -197,11 +203,11 @@ describe("update-center icons", () => { expect(resolveApmIcon(pkgname)).toBe(sharedIconPath); }); - it("builds a remote fallback URL when category and arch are available", async () => { - const { resolveUpdateItemIcon } = await loadIconsModule({}); + it("returns only remoteIcon when no local icon resolves", async () => { + const { resolveUpdateItemIcons } = await loadIconsModule({}); expect( - resolveUpdateItemIcon({ + resolveUpdateItemIcons({ pkgname: "spark-clock", source: "apm", currentVersion: "1.0.0", @@ -209,22 +215,51 @@ describe("update-center icons", () => { category: "utility", arch: "amd64", }), - ).toBe( - "https://erotica.spark-app.store/amd64-apm/utility/spark-clock/icon.png", - ); + ).toEqual({ + remoteIcon: + "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({}); + 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( - resolveUpdateItemIcon({ + 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", }), - ).toBe(""); + ).toEqual({}); }); it("ignores unrelated desktop files while still resolving owned non-exact filenames", async () => { diff --git a/src/__tests__/unit/update-center/load-items.test.ts b/src/__tests__/unit/update-center/load-items.test.ts index ba405796..c2876304 100644 --- a/src/__tests__/unit/update-center/load-items.test.ts +++ b/src/__tests__/unit/update-center/load-items.test.ts @@ -175,7 +175,8 @@ describe("update-center load items", () => { nextVersion: "3.0.0", arch: "amd64", category: "tools", - icon: "https://erotica.spark-app.store/amd64-apm/tools/spark-weather/icon.png", + 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, @@ -235,7 +236,8 @@ describe("update-center load items", () => { nextVersion: "2.0.0", arch: "amd64", category: "office", - icon: "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png", + remoteIcon: + "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png", }, ]); expect(result.warnings).toEqual([ @@ -308,7 +310,8 @@ describe("update-center load items", () => { nextVersion: "2.0.0", arch: "amd64", category: "office", - icon: "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png", + remoteIcon: + "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png", }, ]); }); @@ -362,7 +365,8 @@ describe("update-center load items", () => { nextVersion: "2.0.0", arch: "amd64", category: "office", - icon: "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png", + 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/registerUpdateCenter.test.ts b/src/__tests__/unit/update-center/registerUpdateCenter.test.ts index 76c11b26..f932ec8b 100644 --- a/src/__tests__/unit/update-center/registerUpdateCenter.test.ts +++ b/src/__tests__/unit/update-center/registerUpdateCenter.test.ts @@ -217,9 +217,16 @@ describe("update-center/ipc", () => { expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion"); }); - it("service task snapshots keep item icons for queued work", async () => { + it("service task snapshots keep localIcon and remoteIcon for queued work", async () => { + let releaseTask: (() => void) | undefined; const service = createUpdateCenterService({ - loadItems: async () => [{ ...createItem(), icon: "/icons/weather.png" }], + loadItems: async () => [ + { + ...createItem(), + localIcon: "/icons/weather.png", + remoteIcon: "https://example.com/weather.png", + }, + ], createTaskRunner: (queue: UpdateCenterQueue) => ({ cancelActiveTask: vi.fn(), runNextTask: async () => { @@ -228,6 +235,9 @@ describe("update-center/ipc", () => { return null; } + await new Promise((resolve) => { + releaseTask = resolve; + }); queue.markActiveTask(task.id, "installing"); queue.finishTask(task.id, "completed"); return task; @@ -236,15 +246,20 @@ describe("update-center/ipc", () => { }); await service.refresh(); - await service.start(["aptss:spark-weather"]); + const startPromise = service.start(["aptss:spark-weather"]); + await flushPromises(); expect(service.getState().tasks).toMatchObject([ { taskKey: "aptss:spark-weather", - icon: "/icons/weather.png", - status: "completed", + 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 () => { diff --git a/src/components/update-center/UpdateCenterItem.vue b/src/components/update-center/UpdateCenterItem.vue index 00346f7c..be5decb0 100644 --- a/src/components/update-center/UpdateCenterItem.vue +++ b/src/components/update-center/UpdateCenterItem.vue @@ -92,17 +92,13 @@ const props = defineProps<{ 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(null); +const iconIndex = ref(0); defineEmits<{ (e: "toggle-selection"): void; }>(); -const normalizeIconSrc = (icon?: string): string => { - if (!icon || failedIcon.value === icon) { - return PLACEHOLDER_ICON; - } - +const normalizeIconSrc = (icon: string): string => { if (/^[a-z]+:\/\//i.test(icon)) { return icon; } @@ -110,18 +106,30 @@ const normalizeIconSrc = (icon?: string): string => { return icon.startsWith("/") ? `file://${icon}` : icon; }; +const iconCandidates = computed(() => { + return [props.item.localIcon, props.item.remoteIcon].filter( + (icon): icon is string => Boolean(icon), + ); +}); + const handleIconError = () => { - failedIcon.value = props.item.icon ?? null; + if (iconIndex.value < iconCandidates.value.length) { + iconIndex.value += 1; + } }; watch( - () => props.item, + [() => props.item, () => props.item.localIcon, () => props.item.remoteIcon], () => { - failedIcon.value = null; + iconIndex.value = 0; }, ); -const iconSrc = computed(() => normalizeIconSrc(props.item.icon)); +const iconSrc = computed(() => { + const icon = iconCandidates.value[iconIndex.value]; + + return icon ? normalizeIconSrc(icon) : PLACEHOLDER_ICON; +}); const sourceLabel = computed(() => { return props.item.source === "apm" ? "APM" : "传统deb"; diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index 1f9898c3..d2246253 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -140,7 +140,8 @@ export interface UpdateCenterItem { currentVersion: string; newVersion: string; source: UpdateSource; - icon?: string; + localIcon?: string; + remoteIcon?: string; ignored?: boolean; downloadUrl?: string; fileName?: string; @@ -156,7 +157,8 @@ export interface UpdateCenterTaskState { taskKey: string; packageName: string; source: UpdateSource; - icon?: string; + localIcon?: string; + remoteIcon?: string; status: UpdateCenterTaskStatus; progress: number; logs: Array<{ time: number; message: string }>;