diff --git a/src/App.vue b/src/App.vue index 7991c251..cc6bc309 100644 --- a/src/App.vue +++ b/src/App.vue @@ -322,6 +322,7 @@ import type { ReviewTags, FavoriteFolder, FavoriteItem, + InstalledAppInfo, ResolvedFavoriteItem, } from "./global/typedefinition"; import type { Ref } from "vue"; @@ -1134,6 +1135,95 @@ const refreshInstalledApps = async () => { } }; +const mapInstalledAppToCatalogApp = ( + app: InstalledAppInfo, + origin: "spark" | "apm", +): App | null => { + let appInfo = apps.value.find( + (catalogApp) => + catalogApp.pkgname === app.pkgname && catalogApp.origin === origin, + ); + + if (origin === "spark" && !appInfo) { + return null; + } + + if (appInfo) { + appInfo.flags = app.flags; + appInfo.arch = app.arch; + appInfo.currentStatus = "installed"; + appInfo.isDependency = app.isDependency; + return appInfo; + } + + return { + name: app.name || app.pkgname, + pkgname: app.pkgname, + version: app.version, + category: "unknown", + tags: "", + more: "", + filename: "", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + img_urls: [], + icons: app.icon || "", + origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"), + currentStatus: "installed", + arch: app.arch, + flags: app.flags, + isDependency: app.isDependency, + }; +}; + +const refreshFavoriteInstalledApps = async (): Promise => { + const origins: Array<"spark" | "apm"> = []; + if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) { + origins.push("spark"); + } + if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) { + origins.push("apm"); + } + + const refreshedApps: App[] = []; + await Promise.all( + origins.map(async (origin) => { + const pkgnameList = + origin === "spark" + ? apps.value + .filter((app) => app.origin === "spark") + .map((app) => app.pkgname) + : undefined; + const result = await window.ipcRenderer.invoke("list-installed", { + origin, + pkgnameList, + }); + if (!result?.success) return; + + for (const app of result.apps as InstalledAppInfo[]) { + const appInfo = mapInstalledAppToCatalogApp(app, origin); + if (appInfo) refreshedApps.push(appInfo); + } + }), + ); + + const refreshedKeys = new Set( + refreshedApps.map((app) => `${app.origin}:${app.pkgname}`), + ); + installedApps.value = [ + ...installedApps.value.filter( + (app) => + !origins.includes(app.origin) && + !refreshedKeys.has(`${app.origin}:${app.pkgname}`), + ), + ...refreshedApps, + ]; +}; + const requestUninstall = (app: App) => { uninstallTargetApp.value = app; showUninstallModal.value = true; @@ -1274,7 +1364,7 @@ const refreshFavorites = async (): Promise => { favoriteLoading.value = true; favoriteError.value = ""; try { - await Promise.all([refreshInstalledApps(), loadFavoriteFolders()]); + await Promise.all([refreshFavoriteInstalledApps(), loadFavoriteFolders()]); await loadActiveFavoriteItems(); } catch (error: unknown) { favoriteError.value = (error as Error)?.message || "读取收藏夹失败"; diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts index 2a5fb6c8..cde5ebc3 100644 --- a/src/__tests__/unit/App.account-placeholders.test.ts +++ b/src/__tests__/unit/App.account-placeholders.test.ts @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/vue"; +import { fireEvent, render, screen, waitFor } from "@testing-library/vue"; import { beforeEach, describe, expect, it, vi } from "vitest"; import App from "@/App.vue"; @@ -197,6 +197,10 @@ describe("App account placeholders", () => { }); render(App); + await waitFor(() => { + expect(screen.getByText("2")).toBeTruthy(); + }); + await fireEvent.click(await screen.findByRole("button", { name: /Momen/ })); await fireEvent.click(screen.getByText("我的收藏")); @@ -209,4 +213,50 @@ describe("App account placeholders", () => { pkgnameList: undefined, }); }); + + it("refreshes Spark installed state for favorites in both mode", async () => { + invoke.mockImplementation(async (channel: string, payload?: unknown) => { + if (channel === "get-store-filter") return "both"; + if (channel === "check-spark-available") return true; + if (channel === "check-apm-available") return true; + if (channel === "get-app-version") return "5.0.0"; + if (channel === "list-installed") { + const request = payload as { origin?: string }; + if (request.origin === "spark") { + return { + success: true, + apps: [ + { + pkgname: "wps", + name: "WPS", + version: "1.0.0", + arch: "amd64", + flags: "installed", + origin: "spark", + }, + ], + }; + } + return { success: true, apps: [] }; + } + return []; + }); + render(App); + + await waitFor(() => { + expect(screen.getByText("2")).toBeTruthy(); + }); + + await fireEvent.click(await screen.findByRole("button", { name: /Momen/ })); + await fireEvent.click(screen.getByText("我的收藏")); + + expect( + await screen.findByRole("heading", { name: "我的收藏" }), + ).toBeTruthy(); + expect(await screen.findByText("已安装")).toBeTruthy(); + expect(invoke).toHaveBeenCalledWith("list-installed", { + origin: "spark", + pkgnameList: ["wps"], + }); + }); }); diff --git a/src/__tests__/unit/favoriteAvailability.test.ts b/src/__tests__/unit/favoriteAvailability.test.ts index 5519c0c1..f5158d14 100644 --- a/src/__tests__/unit/favoriteAvailability.test.ts +++ b/src/__tests__/unit/favoriteAvailability.test.ts @@ -112,4 +112,15 @@ describe("favoriteAvailability", () => { )[0]; expect(resolved.status).toBe("installed"); }); + + it("marks installed favorites from all-category catalog matches", () => { + const resolved = resolveFavoriteItems( + [favorite], + [app("spark")], + [app("spark", { category: "all", currentStatus: "installed" })], + { spark: true, apm: true }, + "both", + )[0]; + expect(resolved.status).toBe("installed"); + }); }); diff --git a/src/modules/favoriteAvailability.ts b/src/modules/favoriteAvailability.ts index 4323f855..0c0ba399 100644 --- a/src/modules/favoriteAvailability.ts +++ b/src/modules/favoriteAvailability.ts @@ -19,7 +19,9 @@ const appMatchesFavorite = (app: App, item: FavoriteItem): boolean => const installedAppMatchesFavorite = (app: App, item: FavoriteItem): boolean => app.pkgname === item.pkgname && - (app.category === item.category || app.category === "unknown"); + (app.category === item.category || + app.category === "all" || + app.category === "unknown"); const appMatchesClientArch = (app: App, clientArch: string): boolean => { if (!app.arch) return true;