From 0b784af3d706eeb6aacc33fddebdb9e4d54cc0ba Mon Sep 17 00:00:00 2001 From: momen Date: Thu, 16 Apr 2026 13:04:54 +0800 Subject: [PATCH] fix(sources): hide unavailable update and management entries --- electron/main/backend/install-manager.ts | 55 ++++++ electron/main/backend/update-center/index.ts | 82 +++++++-- .../main/backend/update-center/service.ts | 20 ++- electron/preload/index.ts | 10 +- src/App.vue | 76 +++++++- src/__tests__/unit/AppSidebar.test.ts | 37 ++++ src/__tests__/unit/InstalledAppsModal.test.ts | 6 + src/__tests__/unit/storeFilter.test.ts | 80 +++++++++ .../unit/update-center/load-items.test.ts | 167 +++++++++++++++++- .../registerUpdateCenter.test.ts | 49 ++++- .../unit/update-center/store.test.ts | 4 +- src/components/AppSidebar.vue | 16 +- src/components/InstalledAppsModal.vue | 21 ++- src/global/typedefinition.ts | 6 +- src/modules/storeFilter.ts | 83 +++++++++ src/modules/updateCenter.ts | 13 +- 16 files changed, 667 insertions(+), 58 deletions(-) create mode 100644 src/__tests__/unit/AppSidebar.test.ts create mode 100644 src/__tests__/unit/storeFilter.test.ts create mode 100644 src/modules/storeFilter.ts diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index fe528356..a12351e6 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -10,6 +10,24 @@ import axios from "axios"; const logger = pino({ name: "install-manager" }); +const getStoreFilterFromArgv = (): "spark" | "apm" | "both" => { + const argv = process.argv; + const noApm = argv.includes("--no-apm"); + const noSpark = argv.includes("--no-spark"); + + if (noApm && noSpark) return "both"; + if (noApm) return "spark"; + if (noSpark) return "apm"; + return "both"; +}; + +const isOriginEnabled = ( + storeFilter: "spark" | "apm" | "both", + origin: "spark" | "apm", +): boolean => { + return storeFilter === "both" || storeFilter === origin; +}; + type InstallTask = { id: number; pkgname: string; @@ -88,6 +106,14 @@ const checkApmAvailable = async (): Promise => { return found; }; +/** 检测本机是否具备 Spark/aptss 管理能力 */ +const checkSparkAvailable = async (): Promise => { + const { code, stdout } = await runCommandCapture("which", ["aptss"]); + const found = code === 0 && stdout.trim().length > 0; + if (!found) logger.info("未检测到 aptss 命令"); + return found; +}; + /** 提权执行 shell-caller aptss install apm 安装 APM,安装后需用户重启电脑 */ const runInstallApm = async (superUserCmd: string): Promise => { const execCommand = superUserCmd || SHELL_CALLER_PATH; @@ -793,8 +819,33 @@ ipcMain.handle( payload: { origin: "apm" | "spark"; pkgnameList?: string[] }, ) => { const { origin, pkgnameList } = payload; + const storeFilter = getStoreFilterFromArgv(); const apmBasePath = "/var/lib/apm/apm/files/ace-env/var/lib/apm"; + if (!isOriginEnabled(storeFilter, origin)) { + return { + success: false, + message: `${origin} origin disabled by startup filter`, + apps: [], + }; + } + + if (origin === "spark" && !(await checkSparkAvailable())) { + return { + success: false, + message: "spark origin unavailable on this system", + apps: [], + }; + } + + if (origin === "apm" && !(await checkApmAvailable())) { + return { + success: false, + message: "apm origin unavailable on this system", + apps: [], + }; + } + try { const installedApps: Array<{ pkgname: string; @@ -1033,6 +1084,10 @@ ipcMain.handle("check-apm-available", async () => { return await checkApmAvailable(); }); +ipcMain.handle("check-spark-available", async () => { + return await checkSparkAvailable(); +}); + // 显示 APM 安装对话框(在点击安装按钮时提前检查) ipcMain.handle("show-apm-install-dialog", async (event) => { const webContents = event.sender; diff --git a/electron/main/backend/update-center/index.ts b/electron/main/backend/update-center/index.ts index 5b8a80d6..de04ecc9 100644 --- a/electron/main/backend/update-center/index.ts +++ b/electron/main/backend/update-center/index.ts @@ -12,6 +12,7 @@ import { import { resolveUpdateItemIcons } from "./icons"; import { createUpdateCenterService, + type StoreFilter, type UpdateCenterIgnorePayload, type UpdateCenterService, type UpdateCenterStartTask, @@ -349,35 +350,70 @@ const enrichItemIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => { }); }; +const isSourceEnabled = ( + storeFilter: StoreFilter, + source: "spark" | "apm", +): boolean => { + return storeFilter === "both" || storeFilter === source; +}; + +const isCommandAvailable = async ( + runCommand: UpdateCenterCommandRunner, + command: "aptss" | "apm", +): Promise => { + const result = await runCommand("which", [command]); + return result.code === 0 && result.stdout.trim().length > 0; +}; + export const loadUpdateCenterItems = async ( runCommand: UpdateCenterCommandRunner = runCommandCapture, + storeFilter: StoreFilter = "both", ): Promise => { + const [sparkEnabled, apmEnabled] = await Promise.all([ + isSourceEnabled(storeFilter, "spark") + ? isCommandAvailable(runCommand, "aptss") + : Promise.resolve(false), + isSourceEnabled(storeFilter, "apm") + ? isCommandAvailable(runCommand, "apm") + : Promise.resolve(false), + ]); + const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] = await Promise.all([ - runCommand( - APTSS_LIST_UPGRADABLE_COMMAND.command, - APTSS_LIST_UPGRADABLE_COMMAND.args, - ), - runCommand("apm", ["list", "--upgradable"]), - runCommand( - DPKG_QUERY_INSTALLED_COMMAND.command, - DPKG_QUERY_INSTALLED_COMMAND.args, - ), - runCommand("apm", ["list", "--installed"]), + sparkEnabled + ? runCommand( + APTSS_LIST_UPGRADABLE_COMMAND.command, + APTSS_LIST_UPGRADABLE_COMMAND.args, + ) + : Promise.resolve({ code: 0, stdout: "", stderr: "" }), + apmEnabled + ? runCommand("apm", ["list", "--upgradable"]) + : Promise.resolve({ code: 0, stdout: "", stderr: "" }), + sparkEnabled + ? runCommand( + DPKG_QUERY_INSTALLED_COMMAND.command, + DPKG_QUERY_INSTALLED_COMMAND.args, + ) + : Promise.resolve({ code: 0, stdout: "", stderr: "" }), + apmEnabled + ? runCommand("apm", ["list", "--installed"]) + : Promise.resolve({ code: 0, stdout: "", stderr: "" }), ]); const aptssAvailable = - aptssResult.code === 0 || aptssInstalledResult.code === 0; + sparkEnabled && (aptssResult.code === 0 || aptssInstalledResult.code === 0); const warnings = [ aptssAvailable ? getCommandError("aptss upgradable query", aptssResult) : null, - getCommandError("apm upgradable query", apmResult), + apmEnabled ? getCommandError("apm upgradable query", apmResult) : null, aptssAvailable ? getCommandError("dpkg installed query", aptssInstalledResult) : null, - getCommandError("apm installed query", apmInstalledResult), + apmEnabled + ? getCommandError("apm installed query", apmInstalledResult) + : null, ].filter((message): message is string => message !== null); const aptssItems = @@ -385,7 +421,9 @@ export const loadUpdateCenterItems = async ( ? parseAptssUpgradableOutput(aptssResult.stdout) : []; const apmItems = - apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : []; + apmEnabled && apmResult.code === 0 + ? parseApmUpgradableOutput(apmResult.stdout) + : []; const installedSources = buildInstalledSourceMap( aptssAvailable && aptssInstalledResult.code === 0 @@ -396,13 +434,15 @@ export const loadUpdateCenterItems = async ( const [categorizedAptssItems, categorizedApmItems] = await Promise.all([ aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]), - enrichItemCategories(apmItems), + apmEnabled ? enrichItemCategories(apmItems) : Promise.resolve([]), ]); const [enrichedAptssItems, enrichedApmItems] = await Promise.all([ aptssAvailable ? enrichAptssItems(categorizedAptssItems, runCommand) : Promise.resolve({ items: [], warnings: [] }), - enrichApmItems(categorizedApmItems, runCommand), + apmEnabled + ? enrichApmItems(categorizedApmItems, runCommand) + : Promise.resolve({ items: [], warnings: [] }), ]); return { @@ -433,8 +473,14 @@ export const registerUpdateCenterIpc = ( | "subscribe" >, ): void => { - ipc.handle("update-center-open", () => service.open()); - ipc.handle("update-center-refresh", () => service.refresh()); + ipc.handle( + "update-center-open", + (_event, storeFilter: StoreFilter = "both") => service.open(storeFilter), + ); + ipc.handle( + "update-center-refresh", + (_event, storeFilter: StoreFilter = "both") => service.refresh(storeFilter), + ); ipc.handle( "update-center-ignore", (_event, payload: UpdateCenterIgnorePayload) => service.ignore(payload), diff --git a/electron/main/backend/update-center/service.ts b/electron/main/backend/update-center/service.ts index b865a07c..2ac6f785 100644 --- a/electron/main/backend/update-center/service.ts +++ b/electron/main/backend/update-center/service.ts @@ -13,6 +13,8 @@ import { } from "./queue"; import type { UpdateCenterItem, UpdateSource } from "./types"; +export type StoreFilter = "spark" | "apm" | "both"; + export interface UpdateCenterLoadedItems { items: UpdateCenterItem[]; warnings: string[]; @@ -68,8 +70,8 @@ export interface UpdateCenterStartTask { } export interface UpdateCenterService { - open: () => Promise; - refresh: () => Promise; + open: (storeFilter?: StoreFilter) => Promise; + refresh: (storeFilter?: StoreFilter) => Promise; ignore: (payload: UpdateCenterIgnorePayload) => Promise; unignore: (payload: UpdateCenterIgnorePayload) => Promise; start: (tasks: UpdateCenterStartTask[]) => Promise; @@ -81,7 +83,9 @@ export interface UpdateCenterService { } export interface CreateUpdateCenterServiceOptions { - loadItems: () => Promise; + loadItems: ( + storeFilter: StoreFilter, + ) => Promise; loadIgnoredEntries?: () => Promise>; saveIgnoredEntries?: (entries: ReadonlySet) => Promise; } @@ -135,6 +139,7 @@ export const createUpdateCenterService = ( ): UpdateCenterService => { const queue = createUpdateCenterQueue(); const listeners = new Set<(snapshot: UpdateCenterServiceState) => void>(); + let currentStoreFilter: StoreFilter = "both"; const loadIgnored = options.loadIgnoredEntries ?? (() => loadIgnoredEntries(IGNORE_CONFIG_PATH)); @@ -157,13 +162,18 @@ export const createUpdateCenterService = ( return snapshot; }; - const refresh = async (): Promise => { + const refresh = async ( + storeFilter: StoreFilter = currentStoreFilter, + ): Promise => { + currentStoreFilter = storeFilter; queue.startRefresh(); emit(); try { const ignoredEntries = await loadIgnored(); - const loadedItems = normalizeLoadedItems(await options.loadItems()); + const loadedItems = normalizeLoadedItems( + await options.loadItems(currentStoreFilter), + ); const items = sortIgnoredItems( applyIgnoredEntries(loadedItems.items, ignoredEntries), ); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 13421170..57ba0c6f 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1,5 +1,7 @@ import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron"; +type StoreFilter = "spark" | "apm" | "both"; + type UpdateCenterSnapshot = { items: Array<{ taskKey: string; @@ -90,10 +92,10 @@ contextBridge.exposeInMainWorld("apm_store", { }); contextBridge.exposeInMainWorld("updateCenter", { - open: (): Promise => - ipcRenderer.invoke("update-center-open"), - refresh: (): Promise => - ipcRenderer.invoke("update-center-refresh"), + open: (storeFilter: StoreFilter = "both"): Promise => + ipcRenderer.invoke("update-center-open", storeFilter), + refresh: (storeFilter: StoreFilter = "both"): Promise => + ipcRenderer.invoke("update-center-refresh", storeFilter), ignore: (payload: { packageName: string; newVersion: string; diff --git a/src/App.vue b/src/App.vue index fa9dacd3..3eb99044 100644 --- a/src/App.vue +++ b/src/App.vue @@ -20,6 +20,7 @@ :active-category="activeCategory" :category-counts="categoryCounts" :theme-mode="themeMode" + :spark-available="sparkAvailable" :apm-available="apmAvailable" :store-filter="storeFilter" @toggle-theme="toggleTheme" @@ -120,6 +121,7 @@ :error="installedError" :active-origin="activeInstalledOrigin" :store-filter="storeFilter" + :spark-available="sparkAvailable" :apm-available="apmAvailable" @close="closeInstalledModal" @refresh="refreshInstalledApps" @@ -192,6 +194,12 @@ import { rankAppsBySearch, } from "./modules/appSearch"; import { handleInstall, handleRetry } from "./modules/processInstall"; +import { + getAllowedInstalledOrigin, + getEffectiveStoreFilter, + getDefaultInstalledOrigin, + isOriginEnabled, +} from "./modules/storeFilter"; import { createUpdateCenterStore } from "./modules/updateCenter"; import type { App, @@ -264,10 +272,18 @@ const showUninstallModal = ref(false); const uninstallTargetApp: Ref = ref(null); const showAboutModal = ref(false); const showSettingsModal = ref(false); +const sparkAvailable = ref(false); const apmAvailable = ref(false); /** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */ const storeFilter = ref<"spark" | "apm" | "both">("both"); +const availableSources = computed(() => ({ + spark: sparkAvailable.value, + apm: apmAvailable.value, +})); +const effectiveStoreFilter = computed(() => + getEffectiveStoreFilter(storeFilter.value, availableSources.value), +); // 计算属性 const baseApps = computed(() => { @@ -761,7 +777,11 @@ const handleList = () => { const openUpdateModal = async () => { try { - await updateCenterStore.open(); + if (!effectiveStoreFilter.value) { + return; + } + + await updateCenterStore.open(effectiveStoreFilter.value); } catch (error) { logger.error(`打开更新中心失败: ${error}`); } @@ -791,11 +811,21 @@ const confirmMigrationStart = async () => { }; const openInstalledModal = () => { - showInstalledModal.value = true; - // 如果没有 APM 可用,默认切换到 Spark 应用管理 - if (!apmAvailable.value && activeInstalledOrigin.value === "apm") { - activeInstalledOrigin.value = "spark"; + const defaultOrigin = getDefaultInstalledOrigin( + storeFilter.value, + availableSources.value, + ); + if (!defaultOrigin) { + return; } + + showInstalledModal.value = true; + activeInstalledOrigin.value = + getAllowedInstalledOrigin( + storeFilter.value, + activeInstalledOrigin.value, + availableSources.value, + ) ?? defaultOrigin; refreshInstalledApps(); }; @@ -804,7 +834,12 @@ const closeInstalledModal = () => { }; const handleSwitchOrigin = (origin: "apm" | "spark") => { - activeInstalledOrigin.value = origin; + activeInstalledOrigin.value = + getAllowedInstalledOrigin( + storeFilter.value, + origin, + availableSources.value, + ) ?? activeInstalledOrigin.value; refreshInstalledApps(); }; @@ -812,7 +847,24 @@ const refreshInstalledApps = async () => { installedLoading.value = true; installedError.value = ""; try { - const origin = activeInstalledOrigin.value; + 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; @@ -1151,11 +1203,21 @@ onMounted(async () => { // 从主进程获取启动参数(--no-apm / --no-spark),再加载数据 storeFilter.value = await window.ipcRenderer.invoke("get-store-filter"); + if (storeFilter.value !== "apm") { + sparkAvailable.value = await window.ipcRenderer.invoke( + "check-spark-available", + ); + } + // 检查 apm 是否可用 if (storeFilter.value !== "spark") { apmAvailable.value = await window.ipcRenderer.invoke("check-apm-available"); } + activeInstalledOrigin.value = + getDefaultInstalledOrigin(storeFilter.value, availableSources.value) ?? + "spark"; + await loadCategories(); // 分类目录加载后,并行加载主页数据和所有应用列表 diff --git a/src/__tests__/unit/AppSidebar.test.ts b/src/__tests__/unit/AppSidebar.test.ts new file mode 100644 index 00000000..a67cc7af --- /dev/null +++ b/src/__tests__/unit/AppSidebar.test.ts @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import AppSidebar from "@/components/AppSidebar.vue"; + +const renderSidebar = ( + overrides: Partial["$props"]> = {}, +) => { + return render(AppSidebar, { + props: { + categories: {}, + activeCategory: "all", + categoryCounts: { all: 0 }, + themeMode: "auto", + storeFilter: "both", + sparkAvailable: true, + apmAvailable: true, + ...overrides, + }, + }); +}; + +describe("AppSidebar", () => { + it("shows management and update entries when at least one source is usable", () => { + renderSidebar({ sparkAvailable: true, apmAvailable: false }); + + expect(screen.getByText("应用管理")).toBeTruthy(); + expect(screen.getByText("软件更新")).toBeTruthy(); + }); + + it("hides management and update entries when both sources are unavailable", () => { + renderSidebar({ sparkAvailable: false, apmAvailable: false }); + + expect(screen.queryByText("应用管理")).toBeNull(); + expect(screen.queryByText("软件更新")).toBeNull(); + }); +}); diff --git a/src/__tests__/unit/InstalledAppsModal.test.ts b/src/__tests__/unit/InstalledAppsModal.test.ts index 2beb036d..5236fd44 100644 --- a/src/__tests__/unit/InstalledAppsModal.test.ts +++ b/src/__tests__/unit/InstalledAppsModal.test.ts @@ -35,6 +35,7 @@ describe("InstalledAppsModal", () => { error: "", activeOrigin: "spark", storeFilter: "both", + sparkAvailable: true, apmAvailable: true, }, }); @@ -54,6 +55,7 @@ describe("InstalledAppsModal", () => { error: "", activeOrigin: "spark", storeFilter: "both", + sparkAvailable: true, apmAvailable: true, }, }); @@ -71,6 +73,7 @@ describe("InstalledAppsModal", () => { error: "", activeOrigin: "spark", storeFilter: "both", + sparkAvailable: true, apmAvailable: true, }, }); @@ -92,6 +95,7 @@ describe("InstalledAppsModal", () => { error: "", activeOrigin: "spark", storeFilter: "both", + sparkAvailable: true, apmAvailable: true, }, }); @@ -113,6 +117,7 @@ describe("InstalledAppsModal", () => { error: "", activeOrigin: "spark", storeFilter: "both", + sparkAvailable: true, apmAvailable: true, }, }); @@ -129,6 +134,7 @@ describe("InstalledAppsModal", () => { error: "", activeOrigin: "spark", storeFilter: "both", + sparkAvailable: true, apmAvailable: true, }, }); diff --git a/src/__tests__/unit/storeFilter.test.ts b/src/__tests__/unit/storeFilter.test.ts new file mode 100644 index 00000000..e6ee33fa --- /dev/null +++ b/src/__tests__/unit/storeFilter.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { + getEffectiveStoreFilter, + getAllowedInstalledOrigin, + getDefaultInstalledOrigin, + isOriginEnabled, + isOriginUsable, +} from "@/modules/storeFilter"; + +describe("storeFilter helpers", () => { + it("reports whether an origin is enabled by the current store filter", () => { + expect(isOriginEnabled("both", "spark")).toBe(true); + expect(isOriginEnabled("both", "apm")).toBe(true); + expect(isOriginEnabled("spark", "spark")).toBe(true); + expect(isOriginEnabled("spark", "apm")).toBe(false); + expect(isOriginEnabled("apm", "apm")).toBe(true); + expect(isOriginEnabled("apm", "spark")).toBe(false); + }); + + it("chooses the default installed origin from the active store filter", () => { + expect(getDefaultInstalledOrigin("spark", { spark: true, apm: true })).toBe( + "spark", + ); + expect(getDefaultInstalledOrigin("apm", { spark: true, apm: true })).toBe( + "apm", + ); + expect(getDefaultInstalledOrigin("both", { spark: true, apm: true })).toBe( + "apm", + ); + expect(getDefaultInstalledOrigin("both", { spark: true, apm: false })).toBe( + "spark", + ); + expect( + getDefaultInstalledOrigin("both", { spark: false, apm: false }), + ).toBe(null); + }); + + it("redirects disallowed installed origins to an allowed one", () => { + expect( + getAllowedInstalledOrigin("spark", "apm", { spark: true, apm: true }), + ).toBe("spark"); + expect( + getAllowedInstalledOrigin("apm", "spark", { spark: true, apm: true }), + ).toBe("apm"); + expect( + getAllowedInstalledOrigin("both", "apm", { spark: true, apm: false }), + ).toBe("spark"); + expect( + getAllowedInstalledOrigin("both", "spark", { spark: false, apm: false }), + ).toBeNull(); + }); + + it("computes the effective runtime store filter from source availability", () => { + expect(getEffectiveStoreFilter("both", { spark: true, apm: true })).toBe( + "both", + ); + expect(getEffectiveStoreFilter("both", { spark: true, apm: false })).toBe( + "spark", + ); + expect(getEffectiveStoreFilter("both", { spark: false, apm: true })).toBe( + "apm", + ); + expect(getEffectiveStoreFilter("both", { spark: false, apm: false })).toBe( + null, + ); + }); + + it("only treats enabled and installed origins as usable", () => { + expect(isOriginUsable("both", "spark", { spark: true, apm: false })).toBe( + true, + ); + expect(isOriginUsable("both", "apm", { spark: true, apm: false })).toBe( + false, + ); + expect(isOriginUsable("spark", "apm", { spark: true, apm: true })).toBe( + false, + ); + }); +}); diff --git a/src/__tests__/unit/update-center/load-items.test.ts b/src/__tests__/unit/update-center/load-items.test.ts index 10360552..32e26adf 100644 --- a/src/__tests__/unit/update-center/load-items.test.ts +++ b/src/__tests__/unit/update-center/load-items.test.ts @@ -25,6 +25,9 @@ const APTSS_WEATHER_PRINT_URIS_KEY = const APTSS_NOTES_PRINT_URIS_KEY = "bash -lc /usr/bin/apt download spark-notes --print-uris -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null"; +const WHICH_APTSS_KEY = "which aptss"; +const WHICH_APM_KEY = "which apm"; + const loadUpdateCenterModule = async ( remoteStore: Record, ) => { @@ -106,6 +109,22 @@ afterEach(() => { describe("update-center load items", () => { it("enriches apm migration items with download metadata and remote fallback icons", async () => { const commandResults = new Map([ + [ + WHICH_APTSS_KEY, + { + code: 0, + stdout: "/usr/bin/aptss\n", + stderr: "", + }, + ], + [ + WHICH_APM_KEY, + { + code: 0, + stdout: "/usr/bin/apm\n", + stderr: "", + }, + ], [ APTSS_LIST_UPGRADABLE_KEY, { @@ -217,6 +236,14 @@ describe("update-center load items", () => { const result = await loadUpdateCenterItems(async (command, args) => { const key = `${command} ${args.join(" ")}`; + if (key === WHICH_APTSS_KEY) { + return { code: 0, stdout: "/usr/bin/aptss\n", stderr: "" }; + } + + if (key === WHICH_APM_KEY) { + return { code: 127, stdout: "", stderr: "apm: command not found" }; + } + if (key === APTSS_LIST_UPGRADABLE_KEY) { return { code: 0, @@ -279,10 +306,7 @@ describe("update-center load items", () => { sha512: "beadfeed", }, ]); - expect(result.warnings).toEqual([ - "apm upgradable query failed: apm: command not found", - "apm installed query failed: apm: command not found", - ]); + expect(result.warnings).toEqual([]); }); it("retries category lookup after an earlier fetch failure in the same process", async () => { @@ -292,6 +316,14 @@ describe("update-center load items", () => { const runCommand = async (command: string, args: string[]) => { const key = `${command} ${args.join(" ")}`; + if (key === WHICH_APTSS_KEY) { + return { code: 0, stdout: "/usr/bin/aptss\n", stderr: "" }; + } + + if (key === WHICH_APM_KEY) { + return { code: 127, stdout: "", stderr: "apm: command not found" }; + } + if (key === APTSS_LIST_UPGRADABLE_KEY) { return { code: 0, @@ -387,6 +419,14 @@ describe("update-center load items", () => { const result = await loadUpdateCenterItems(async (command, args) => { const key = `${command} ${args.join(" ")}`; + if (key === WHICH_APTSS_KEY) { + return { code: 0, stdout: "/usr/bin/aptss\n", stderr: "" }; + } + + if (key === WHICH_APM_KEY) { + return { code: 127, stdout: "", stderr: "apm: command not found" }; + } + if (key === APTSS_LIST_UPGRADABLE_KEY) { return { code: 0, @@ -440,9 +480,122 @@ describe("update-center load items", () => { sha512: "beadfeed", }, ]); - expect(result.warnings).toEqual([ - "apm upgradable query failed: apm: command not found", - "apm installed query failed: apm: command not found", + expect(result.warnings).toEqual([]); + }); + + it("skips aptss commands when the store filter disables Spark", async () => { + const { loadUpdateCenterItems } = await loadUpdateCenterModule({ + "https://erotica.spark-app.store/amd64-apm/categories.json": { + tools: { zh: "Tools" }, + }, + "https://erotica.spark-app.store/amd64-apm/tools/applist.json": [ + { Name: "Spark Clock", Pkgname: "spark-clock" }, + ], + }); + + const runCommand = vi.fn(async (command: string, args: string[]) => { + const key = `${command} ${args.join(" ")}`; + + if (key === WHICH_APM_KEY) { + return { code: 0, stdout: "/usr/bin/apm\n", stderr: "" }; + } + + if (key === "apm list --upgradable") { + return { + code: 0, + stdout: "spark-clock/main 2.0.0 amd64 [upgradable from: 1.0.0]", + stderr: "", + }; + } + + if (key === "apm list --installed") { + return { + code: 0, + stdout: "", + stderr: "", + }; + } + + if ( + key === + "bash -lc amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download spark-clock --print-uris" + ) { + return { + code: 0, + stdout: + "'https://example.invalid/spark-clock_2.0.0_amd64.deb' spark-clock_2.0.0_amd64.deb 1234 SHA512:feedface", + stderr: "", + }; + } + + throw new Error(`Unexpected command ${key}`); + }); + + await loadUpdateCenterItems(runCommand, "apm"); + + expect(runCommand).not.toHaveBeenCalledWith( + "bash", + expect.arrayContaining([ + expect.stringContaining("apt list --upgradable"), + ]), + ); + expect(runCommand).not.toHaveBeenCalledWith( + "dpkg-query", + expect.any(Array), + ); + }); + + it("skips apm commands when the store filter disables APM", 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": [ + { Name: "Spark Notes", Pkgname: "spark-notes" }, + ], + }); + + const runCommand = vi.fn(async (command: string, args: string[]) => { + const key = `${command} ${args.join(" ")}`; + + if (key === WHICH_APTSS_KEY) { + return { code: 0, stdout: "/usr/bin/aptss\n", stderr: "" }; + } + + 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 === APTSS_NOTES_PRINT_URIS_KEY) { + return { + code: 0, + stdout: + "'https://example.invalid/spark-notes_2.0.0_amd64.deb' spark-notes_2.0.0_amd64.deb 654321 SHA512:beadfeed", + stderr: "", + }; + } + + throw new Error(`Unexpected command ${key}`); + }); + + await loadUpdateCenterItems(runCommand, "spark"); + + expect(runCommand).not.toHaveBeenCalledWith("apm", [ + "list", + "--upgradable", ]); + expect(runCommand).not.toHaveBeenCalledWith("apm", ["list", "--installed"]); }); }); diff --git a/src/__tests__/unit/update-center/registerUpdateCenter.test.ts b/src/__tests__/unit/update-center/registerUpdateCenter.test.ts index 37c9c405..64940c21 100644 --- a/src/__tests__/unit/update-center/registerUpdateCenter.test.ts +++ b/src/__tests__/unit/update-center/registerUpdateCenter.test.ts @@ -157,8 +157,8 @@ describe("update-center/ipc", () => { await cancelHandler?.({}, "aptss:spark-weather"); expect(getStateHandler?.()).toEqual(snapshot); - expect(service.open).toHaveBeenCalledTimes(1); - expect(service.refresh).toHaveBeenCalledTimes(1); + expect(service.open).toHaveBeenCalledWith("both"); + expect(service.refresh).toHaveBeenCalledWith("both"); expect(service.ignore).toHaveBeenCalledWith({ packageName: "spark-weather", newVersion: "2.0.0", @@ -176,6 +176,51 @@ describe("update-center/ipc", () => { expect(send).toHaveBeenCalledWith("update-center-state", snapshot); }); + it("forwards store filter payloads to open and refresh", async () => { + const handle = vi.fn(); + const snapshot: UpdateCenterServiceState = { + items: [], + tasks: [], + warnings: [], + hasRunningTasks: false, + }; + const service = { + open: vi.fn().mockResolvedValue(snapshot), + refresh: vi.fn().mockResolvedValue(snapshot), + ignore: vi.fn().mockResolvedValue(undefined), + unignore: vi.fn().mockResolvedValue(undefined), + start: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn().mockResolvedValue(undefined), + getState: vi.fn().mockReturnValue(snapshot), + subscribe: vi.fn(() => () => undefined), + }; + + registerUpdateCenterIpc({ handle }, service); + + const openHandler = handle.mock.calls.find( + ([channel]: [string]) => channel === "update-center-open", + )?.[1] as + | (( + event: unknown, + storeFilter?: "spark" | "apm" | "both", + ) => Promise) + | undefined; + const refreshHandler = handle.mock.calls.find( + ([channel]: [string]) => channel === "update-center-refresh", + )?.[1] as + | (( + event: unknown, + storeFilter?: "spark" | "apm" | "both", + ) => Promise) + | undefined; + + await openHandler?.({}, "apm"); + await refreshHandler?.({}, "spark"); + + expect(service.open).toHaveBeenCalledWith("apm"); + expect(service.refresh).toHaveBeenCalledWith("spark"); + }); + it("service subscribers receive state updates after refresh start and ignore", async () => { let ignoredEntries = new Set(); const send = vi.fn(); diff --git a/src/__tests__/unit/update-center/store.test.ts b/src/__tests__/unit/update-center/store.test.ts index 4648370e..2f0da84a 100644 --- a/src/__tests__/unit/update-center/store.test.ts +++ b/src/__tests__/unit/update-center/store.test.ts @@ -61,9 +61,9 @@ describe("updateCenter store", () => { open.mockResolvedValue(snapshot); const store = createUpdateCenterStore(); - await store.open(); + await store.open("apm"); - expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith("apm"); expect(store.isOpen.value).toBe(true); expect(store.snapshot.value).toEqual(snapshot); expect(store.filteredItems.value).toEqual(snapshot.items); diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index 5ace5b57..124a373b 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -89,7 +89,7 @@