diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index 4dbd985d..22d4732b 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -997,8 +997,16 @@ ipcMain.handle( }> = []; if (origin === "spark") { - // 如果提供了包名列表,只检查这些包的安装状态(优化版) - if (pkgnameList && pkgnameList.length > 0) { + // 显式传入了包名列表(可能是空数组):只检查这些包的安装状态(优化版) + if (Array.isArray(pkgnameList)) { + if (pkgnameList.length === 0) { + // 商店目录尚未加载或该来源没有任何可枚举的包时, + // 直接返回空列表,避免退化为“全量扫描整个系统”后再被渲染端全部跳过, + // 否则会误报“已安装应用为空”。 + logger.info("Spark 包名列表为空,跳过已安装检查"); + return { success: true, apps: [] }; + } + logger.info( `使用优化模式检查 ${pkgnameList.length} 个 Spark 包的安装状态`, ); @@ -1040,7 +1048,7 @@ ipcMain.handle( return { success: true, apps: installedApps }; } - // 回退到全量扫描模式(未提供包名列表时) + // 回退到全量扫描模式(仅当调用方未传入 pkgnameList 时,例如旧版直接调用) logger.info("使用全量扫描模式获取所有 Spark 已安装包"); const { code, stdout } = await runCommandCapture("dpkg-query", [ "-W", diff --git a/src/App.vue b/src/App.vue index 3c83aaa2..b22fefae 100644 --- a/src/App.vue +++ b/src/App.vue @@ -172,10 +172,6 @@ :apps="installedApps" :loading="installedLoading" :error="installedError" - :active-origin="activeInstalledOrigin" - :store-filter="storeFilter" - :spark-available="sparkAvailable" - :apm-available="apmAvailable" :logged-in="isLoggedIn" :syncing="syncLoading" :sync-message="syncStatusMessage" @@ -184,7 +180,6 @@ @open-app="openDownloadedApp($event.pkgname, $event.origin)" @open-detail="openDetail" @uninstall="uninstallInstalledApp" - @switch-origin="handleSwitchOrigin" @sync-to-account="syncInstalledAppsToAccount" @restore-from-account="openRestoreFromAccount" @request-login="requireLogin('云端同步需要登录星火账号。')" @@ -362,10 +357,9 @@ import { setAuthSession, } from "./global/authState"; import { - getAllowedInstalledOrigin, getEffectiveStoreFilter, - getDefaultInstalledOrigin, isOriginEnabled, + isOriginUsable, } from "./modules/storeFilter"; import { createUpdateCenterStore } from "./modules/updateCenter"; import { @@ -468,7 +462,6 @@ const loading = ref(true); const showDownloadDetailModal = ref(false); const currentDownload: Ref = ref(null); const showInstalledModal = ref(false); -const activeInstalledOrigin = ref<"apm" | "spark">("apm"); const installedApps = ref([]); const installedLoading = ref(false); const installedError = ref(""); @@ -1379,21 +1372,11 @@ const confirmMigrationStart = async () => { }; const openInstalledModal = () => { - const defaultOrigin = getDefaultInstalledOrigin( - storeFilter.value, - availableSources.value, - ); - if (!defaultOrigin) { + if (getEffectiveStoreFilter(storeFilter.value, availableSources.value) === null) { return; } showInstalledModal.value = true; - activeInstalledOrigin.value = - getAllowedInstalledOrigin( - storeFilter.value, - activeInstalledOrigin.value, - availableSources.value, - ) ?? defaultOrigin; refreshInstalledApps(); }; @@ -1401,100 +1384,116 @@ const closeInstalledModal = () => { showInstalledModal.value = false; }; -const handleSwitchOrigin = (origin: "apm" | "spark") => { - activeInstalledOrigin.value = - getAllowedInstalledOrigin( - storeFilter.value, - origin, - availableSources.value, - ) ?? activeInstalledOrigin.value; - refreshInstalledApps(); +// 根据当前启动模式和系统可用性,确定需要查询哪些 origin 的已安装应用 +const resolveInstalledOrigins = (): Array<"spark" | "apm"> => { + const origins: Array<"spark" | "apm"> = []; + if (isOriginUsable(storeFilter.value, "spark", availableSources.value)) { + origins.push("spark"); + } + if (isOriginUsable(storeFilter.value, "apm", availableSources.value)) { + origins.push("apm"); + } + return origins; }; -const refreshInstalledApps = async () => { - installedLoading.value = true; - installedError.value = ""; - try { - const origin = getAllowedInstalledOrigin( - storeFilter.value, - activeInstalledOrigin.value, - availableSources.value, - ); - if (!origin) { - installedApps.value = []; - installedError.value = "当前系统不可用应用管理功能"; - return; - } - - activeInstalledOrigin.value = origin; - - if (!isOriginEnabled(storeFilter.value, origin)) { - installedApps.value = []; - installedError.value = `当前启动模式已禁用 ${origin === "spark" ? "Spark" : "APM"} 软件管理`; - return; - } - - // Spark 优化:只检查远端商店目录中的应用,避免全量扫描 - let pkgnameList: string[] | undefined; - if (origin === "spark") { - pkgnameList = apps.value + const refreshInstalledApps = async () => { + installedLoading.value = true; + installedError.value = ""; + try { + const origins = resolveInstalledOrigins(); + // Spark 已安装列表依赖商店目录(apps.value)来枚举包名。 + // 仅当目录中存在可枚举的 Spark 包时才查询 Spark, + // 否则空目录会触发“全量扫描整个系统”的陷阱导致列表被全部跳过而误报为空。 + const sparkPkgnameList = apps.value .filter((a) => a.origin === "spark") .map((a) => a.pkgname); - } - - const result = await window.ipcRenderer.invoke("list-installed", { - origin, - pkgnameList, - }); - if (!result?.success) { - installedApps.value = []; - installedError.value = result?.message || "读取已安装应用失败"; - return; - } - - installedApps.value = []; - for (const app of result.apps) { - // Find matching remote app to enrich data. We look exactly for that origin. - let appInfo = apps.value.find( - (a) => a.pkgname === app.pkgname && a.origin === origin, + const effectiveOrigins = origins.filter( + (o) => o !== "spark" || sparkPkgnameList.length > 0, ); - if (origin === "spark" && !appInfo) { - // Only show Spark packages that exist in the App Store catalogue + if (effectiveOrigins.length === 0) { + installedApps.value = []; + // 目录尚未加载完成(但来源可用)时给出过渡提示,待目录加载后会自动重查 + installedError.value = + apps.value.length === 0 + ? "正在加载应用目录,请稍候…" + : "当前系统不可用应用管理功能"; + return; + } + + // 并行查询每个 origin 的已安装应用 + const results = await Promise.all( + effectiveOrigins.map((origin) => + window.ipcRenderer.invoke("list-installed", { + origin, + pkgnameList: origin === "spark" ? sparkPkgnameList : undefined, + }), + ), + ); + + const combinedApps: App[] = []; + const failedOrigins: string[] = []; + + for (let i = 0; i < effectiveOrigins.length; i++) { + const origin = effectiveOrigins[i]; + const result = results[i]; + if (!result?.success) { + failedOrigins.push(origin); continue; } - if (appInfo) { - appInfo.flags = app.flags; - appInfo.arch = app.arch; - appInfo.currentStatus = "installed"; - appInfo.isDependency = app.isDependency; - } else { - // 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象 - appInfo = { - 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, - }; + for (const app of result.apps) { + // Find matching remote app to enrich data. We look exactly for that origin. + let appInfo = apps.value.find( + (a) => a.pkgname === app.pkgname && a.origin === origin, + ); + + if (origin === "spark" && !appInfo) { + // Only show Spark packages that exist in the App Store catalogue + continue; + } + + if (appInfo) { + appInfo.flags = app.flags; + appInfo.arch = app.arch; + appInfo.currentStatus = "installed"; + appInfo.isDependency = app.isDependency; + } else { + // 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象 + appInfo = { + 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, + }; + } + combinedApps.push(appInfo); } - installedApps.value.push(appInfo); + } + + installedApps.value = combinedApps; + + if (combinedApps.length === 0 && failedOrigins.length > 0) { + const labels = failedOrigins + .map((o) => (o === "spark" ? "Spark" : "APM")) + .join("、"); + installedError.value = `读取${labels}已安装应用失败`; } } catch (error: unknown) { installedApps.value = []; @@ -1504,6 +1503,22 @@ const refreshInstalledApps = async () => { } }; +// 应用目录(apps.value)可能在打开“已安装应用”模态框之后才加载完成。 +// 当目录从无到有、且模态框处于打开状态时,自动重查已安装应用,避免列表一直为空。 +watch( + () => apps.value.length, + (len, prevLen) => { + if ( + showInstalledModal.value && + prevLen === 0 && + len > 0 && + !installedLoading.value + ) { + void refreshInstalledApps(); + } + }, +); + const mapInstalledAppToCatalogApp = ( app: InstalledAppInfo, origin: "spark" | "apm", @@ -2827,10 +2842,6 @@ onMounted(async () => { apmAvailable.value = await window.ipcRenderer.invoke("check-apm-available"); } - activeInstalledOrigin.value = - getDefaultInstalledOrigin(storeFilter.value, availableSources.value) ?? - "spark"; - await loadCategories(); await loadSidebarConfig(); diff --git a/src/__tests__/unit/InstalledAppsModal.test.ts b/src/__tests__/unit/InstalledAppsModal.test.ts index f9cac7a8..59f00594 100644 --- a/src/__tests__/unit/InstalledAppsModal.test.ts +++ b/src/__tests__/unit/InstalledAppsModal.test.ts @@ -26,21 +26,19 @@ const createApp = (overrides: Partial = {}): App => ({ }); describe("InstalledAppsModal", () => { + const baseProps = { + show: true, + apps: [] as App[], + loading: false, + error: "", + loggedIn: false, + syncing: false, + syncMessage: "", + }; + it("keeps scroll chaining inside the modal list", () => { const { container } = render(InstalledAppsModal, { - props: { - show: true, - apps: [], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", - }, + props: baseProps, }); expect(screen.getByText("已安装应用")).toBeTruthy(); @@ -52,17 +50,8 @@ describe("InstalledAppsModal", () => { it("renders open and detail actions for a store-backed installed app", () => { render(InstalledAppsModal, { props: { - show: true, + ...baseProps, apps: [createApp()], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", }, }); @@ -70,20 +59,42 @@ describe("InstalledAppsModal", () => { expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy(); }); + it("renders the spark origin tag for spark apps", () => { + render(InstalledAppsModal, { + props: { + ...baseProps, + apps: [createApp({ origin: "spark", name: "Spark Notes" })], + }, + }); + + // 页头统计区也含 "Spark" 标签,故用 getAllByText 校验至少一个匹配 + expect(screen.getAllByText("Spark").length).toBeGreaterThanOrEqual(1); + }); + + it("renders the APM origin tag for APM apps", () => { + render(InstalledAppsModal, { + props: { + ...baseProps, + apps: [ + createApp({ + origin: "apm", + name: "APM Container", + pkgname: "amber-pm-container", + version: "1.0.0", + }), + ], + }, + }); + + // 页头统计区也含 "APM" 标签,故用 getAllByText 校验至少一个匹配 + expect(screen.getAllByText("APM").length).toBeGreaterThanOrEqual(1); + }); + it("emits open-app when clicking 打开", async () => { const rendered = render(InstalledAppsModal, { props: { - show: true, + ...baseProps, apps: [createApp()], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", }, }); @@ -98,17 +109,8 @@ describe("InstalledAppsModal", () => { it("emits open-detail when clicking 查看详情", async () => { const rendered = render(InstalledAppsModal, { props: { - show: true, + ...baseProps, apps: [createApp()], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", }, }); @@ -123,17 +125,8 @@ describe("InstalledAppsModal", () => { it("shows 查看详情 for metadata-rich unknown-category apps", () => { render(InstalledAppsModal, { props: { - show: true, + ...baseProps, apps: [createApp({ category: "unknown", more: "Has store metadata" })], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", }, }); @@ -143,17 +136,8 @@ describe("InstalledAppsModal", () => { it("hides 查看详情 for unknown-category apps", () => { render(InstalledAppsModal, { props: { - show: true, + ...baseProps, apps: [createApp({ category: "unknown" })], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", }, }); @@ -162,19 +146,7 @@ describe("InstalledAppsModal", () => { it("requests login for cloud actions when logged out", async () => { const rendered = render(InstalledAppsModal, { - props: { - show: true, - apps: [], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", - }, + props: baseProps, }); await fireEvent.click(screen.getByRole("button", { name: "同步到账号" })); @@ -186,17 +158,8 @@ describe("InstalledAppsModal", () => { it("emits cloud sync and restore events when logged in", async () => { const rendered = render(InstalledAppsModal, { props: { - show: true, - apps: [], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, + ...baseProps, loggedIn: true, - syncing: false, - syncMessage: "", }, }); @@ -210,17 +173,9 @@ describe("InstalledAppsModal", () => { it("disables sync button while syncing", () => { render(InstalledAppsModal, { props: { - show: true, - apps: [], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, + ...baseProps, loggedIn: true, syncing: true, - syncMessage: "", }, }); @@ -230,16 +185,8 @@ describe("InstalledAppsModal", () => { it("shows account sync feedback in the installed apps modal", () => { render(InstalledAppsModal, { props: { - show: true, - apps: [], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, + ...baseProps, loggedIn: true, - syncing: false, syncMessage: "同步完成", }, }); diff --git a/src/components/InstalledAppsModal.vue b/src/components/InstalledAppsModal.vue index a7d28b32..e026fe41 100644 --- a/src/components/InstalledAppsModal.vue +++ b/src/components/InstalledAppsModal.vue @@ -19,66 +19,87 @@
-
+

已安装应用

管理本机安装的应用程序

+
+ +
+ + + +
+ {{ apmCount }} + APM +
+
+ + + +
+ + + +
+ {{ sparkCount }} + Spark +
+
+ + + +
+ + + +
+ {{ totalCount }} + 总数 +
+
+
- -
- - -