# Installed Apps And Update Center Loading 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:** Make the update center open immediately with visible loading feedback, switch Spark installed-app discovery to desktop entries under `/usr/share/applications`, and let users open installed apps from the management modal. **Architecture:** Keep the existing Electron IPC contracts in place. Add a renderer-only loading flag to the update-center store for immediate modal display, move Spark desktop discovery into a focused main-process helper that returns `InstalledAppInfo`-shaped records, and move installed-app normalization into a small renderer helper so `App.vue` can accept local desktop apps even when they are absent from the remote catalog. **Tech Stack:** Vue 3 Composition API, TypeScript, Electron IPC, Node `fs/path/child_process`, Vitest, Testing Library Vue --- ## File Structure - Create: `electron/main/backend/sparkInstalledApps.ts` Responsibility: scan `/usr/share/applications`, parse desktop files, resolve owning packages with `dpkg -S`, and return normalized Spark installed-app records. - Create: `src/modules/installedApps.ts` Responsibility: convert `list-installed` results into `App` objects without filtering out Spark apps that are missing from the remote catalog. - Create: `src/__tests__/unit/sparkInstalledApps.test.ts` Responsibility: regression coverage for Spark desktop discovery, deduping, and failure handling. - Create: `src/__tests__/unit/installedApps.test.ts` Responsibility: regression coverage for installed-app normalization and Spark fallback cards. - Modify: `src/modules/updateCenter.ts` Responsibility: expose `loading` on the update-center store and make `open()` show the modal before the first IPC result returns. - Modify: `src/components/UpdateCenterModal.vue` Responsibility: show the initial loading state and the lighter refresh-in-progress state. - Modify: `src/components/update-center/UpdateCenterToolbar.vue` Responsibility: disable and visually update the refresh button while loading. - Modify: `src/__tests__/unit/update-center/store.test.ts` Responsibility: prove the store opens immediately and toggles loading around `open()` and `refresh()`. - Modify: `src/__tests__/unit/update-center/UpdateCenterModal.test.ts` Responsibility: prove the loading panel and disabled refresh state render correctly. - Modify: `electron/main/backend/install-manager.ts` Responsibility: replace the inline Spark `dpkg-query -W` listing branch with the new desktop-discovery helper. - Modify: `src/components/InstalledAppsModal.vue` Responsibility: emit `open-app` and render the new `打开` action beside `卸载`. - Modify: `src/__tests__/unit/InstalledAppsModal.test.ts` Responsibility: prove the modal emits `open-app` and still keeps scroll chaining contained. - Modify: `src/App.vue` Responsibility: wire `InstalledAppsModal` to `openDownloadedApp()` and use the installed-app normalization helper instead of filtering Spark apps out when they are missing from the remote catalog. ### Task 1: Update Center Store Loading Lifecycle **Files:** - Modify: `src/modules/updateCenter.ts` - Test: `src/__tests__/unit/update-center/store.test.ts` - [ ] **Step 1: Write the failing tests** ```ts import { beforeEach, describe, expect, it, vi } from "vitest"; import { createUpdateCenterStore } from "@/modules/updateCenter"; import { downloads } from "@/global/downloadStatus"; const createSnapshot = (overrides = {}) => ({ items: [ { taskKey: "aptss:spark-weather", packageName: "spark-weather", displayName: "Spark Weather", currentVersion: "1.0.0", newVersion: "2.0.0", source: "aptss" as const, ignored: false, }, ], tasks: [], warnings: [], hasRunningTasks: false, ...overrides, }); const createDeferred = () => { let resolve!: (value: T) => void; const promise = new Promise((nextResolve) => { resolve = nextResolve; }); return { promise, resolve }; }; describe("updateCenter store", () => { const open = vi.fn(); const refresh = vi.fn(); const start = vi.fn(); const onState = vi.fn(); const offState = vi.fn(); beforeEach(() => { open.mockReset(); refresh.mockReset(); start.mockReset(); onState.mockReset(); offState.mockReset(); downloads.value = []; Object.defineProperty(window, "updateCenter", { configurable: true, value: { open, refresh, ignore: vi.fn(), unignore: vi.fn(), start, cancel: vi.fn(), getState: vi.fn(), onState, offState, }, }); }); it("opens the modal immediately while waiting for the first snapshot", async () => { const deferred = createDeferred>(); open.mockReturnValue(deferred.promise); const store = createUpdateCenterStore(); const openPromise = store.open(); expect(store.isOpen.value).toBe(true); expect(store.loading.value).toBe(true); deferred.resolve(createSnapshot()); await openPromise; expect(store.snapshot.value).toEqual(createSnapshot()); expect(store.loading.value).toBe(false); }); it("toggles loading around refresh", async () => { const deferred = createDeferred>(); refresh.mockReturnValue(deferred.promise); const store = createUpdateCenterStore(); const refreshPromise = store.refresh(); expect(store.loading.value).toBe(true); deferred.resolve(createSnapshot({ warnings: ["refresh finished"] })); await refreshPromise; expect(store.loading.value).toBe(false); expect(store.snapshot.value.warnings).toEqual(["refresh finished"]); }); }); ``` - [ ] **Step 2: Run the store test file to verify it fails** Run: `npx vitest run src/__tests__/unit/update-center/store.test.ts` Expected: FAIL because `UpdateCenterStore` does not expose `loading`, and `open()` only sets `isOpen` after awaiting `window.updateCenter.open()`. - [ ] **Step 3: Write the minimal store implementation** ```ts import { computed, ref, type ComputedRef, type Ref } from "vue"; export interface UpdateCenterStore { isOpen: Ref; loading: Ref; showCloseConfirm: Ref; showMigrationConfirm: Ref; searchQuery: Ref; selectedTaskKeys: Ref>; snapshot: Ref; filteredItems: ComputedRef; allSelected: ComputedRef; someSelected: ComputedRef; bind: () => void; unbind: () => void; open: () => Promise; refresh: () => Promise; toggleSelection: (taskKey: string) => void; toggleSelectAll: () => void; getSelectedItems: () => UpdateCenterItem[]; closeNow: () => void; startSelected: () => Promise; requestClose: () => void; } export const createUpdateCenterStore = (): UpdateCenterStore => { const isOpen = ref(false); const loading = ref(false); const showCloseConfirm = ref(false); const showMigrationConfirm = ref(false); const searchQuery = ref(""); const selectedTaskKeys = ref(new Set()); const snapshot = ref(EMPTY_SNAPSHOT); const resetSessionState = (): void => { showCloseConfirm.value = false; showMigrationConfirm.value = false; searchQuery.value = ""; selectedTaskKeys.value = new Set(); }; const open = async (): Promise => { resetSessionState(); isOpen.value = true; loading.value = true; try { const nextSnapshot = await window.updateCenter.open(); applySnapshot(nextSnapshot); } finally { loading.value = false; } }; const refresh = async (): Promise => { loading.value = true; try { const nextSnapshot = await window.updateCenter.refresh(); applySnapshot(nextSnapshot); } finally { loading.value = false; } }; const closeNow = (): void => { resetSessionState(); loading.value = false; isOpen.value = false; }; return { isOpen, loading, showCloseConfirm, showMigrationConfirm, searchQuery, selectedTaskKeys, snapshot, filteredItems, allSelected, someSelected, bind, unbind, open, refresh, toggleSelection, toggleSelectAll, getSelectedItems, closeNow, startSelected, requestClose, }; }; ``` - [ ] **Step 4: Re-run the store test file** Run: `npx vitest run src/__tests__/unit/update-center/store.test.ts` Expected: PASS with both new loading-lifecycle tests green. - [ ] **Step 5: Commit the store-loading change** ```bash git add src/modules/updateCenter.ts src/__tests__/unit/update-center/store.test.ts git commit -m "fix(update-center): show loading before updates load" ``` ### Task 2: Update Center Loading UI **Files:** - Modify: `src/components/UpdateCenterModal.vue` - Modify: `src/components/update-center/UpdateCenterToolbar.vue` - Test: `src/__tests__/unit/update-center/UpdateCenterModal.test.ts` - [ ] **Step 1: Write the failing loading-state UI tests** ```ts import { computed, ref } from "vue"; import { fireEvent, render, screen } from "@testing-library/vue"; import { describe, expect, it, vi } from "vitest"; import UpdateCenterModal from "@/components/UpdateCenterModal.vue"; import type { UpdateCenterItem, UpdateCenterSnapshot, UpdateCenterTaskState, } from "@/global/typedefinition"; import type { UpdateCenterStore } from "@/modules/updateCenter"; const createItem = ( overrides: Partial = {}, ): UpdateCenterItem => ({ 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, }); const createStore = ( overrides: Partial = {}, ): UpdateCenterStore => { const snapshot = ref({ items: [ createItem({ taskKey: "aptss:spark-weather", source: "aptss", }), createItem({ taskKey: "apm:spark-clock", packageName: "spark-clock", displayName: "Spark Clock", source: "apm", isMigration: true, migrationTarget: "apm", }), ], tasks: [createTask()], warnings: ["更新过程中请勿关闭商店"], hasRunningTasks: true, ...overrides, }); const selectedTaskKeys = ref(new Set(["aptss:spark-weather"])); return { isOpen: ref(true), loading: ref(false), showCloseConfirm: ref(true), showMigrationConfirm: ref(false), searchQuery: ref(""), selectedTaskKeys, snapshot, filteredItems: computed(() => snapshot.value.items), allSelected: computed(() => true), someSelected: computed(() => false), bind: vi.fn(), unbind: vi.fn(), open: vi.fn(), refresh: vi.fn(), toggleSelection: vi.fn(), toggleSelectAll: vi.fn(), getSelectedItems: vi.fn(() => snapshot.value.items.filter( (item) => selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true, ), ), closeNow: vi.fn(), startSelected: vi.fn(), requestClose: vi.fn(), }; }; describe("UpdateCenterModal", () => { it("shows an initial loading panel and disables refresh while loading", () => { const store = createStore({ items: [], tasks: [], warnings: [], hasRunningTasks: false, }); store.loading.value = true; render(UpdateCenterModal, { props: { show: true, store, }, }); expect(screen.getByText("正在检查更新…")).toBeTruthy(); expect(screen.getByRole("button", { name: /刷新/ })).toBeDisabled(); }); it("keeps existing items visible while showing the refresh hint", () => { const store = createStore({ hasRunningTasks: false }); store.loading.value = true; render(UpdateCenterModal, { props: { show: true, store, }, }); expect(screen.getByText("Spark Weather")).toBeTruthy(); expect(screen.getByText("正在刷新更新列表…")).toBeTruthy(); }); }); ``` - [ ] **Step 2: Run the modal test file to verify it fails** Run: `npx vitest run src/__tests__/unit/update-center/UpdateCenterModal.test.ts` Expected: FAIL because the toolbar does not accept a `loading` prop yet, and the modal renders neither `正在检查更新…` nor `正在刷新更新列表…`. - [ ] **Step 3: Implement the loading UI in the modal and toolbar** ```vue ``` ```vue ``` - [ ] **Step 4: Re-run the modal test file** Run: `npx vitest run src/__tests__/unit/update-center/UpdateCenterModal.test.ts` Expected: PASS with the new loading-panel and refresh-disabled assertions green. - [ ] **Step 5: Commit the loading UI change** ```bash git add src/components/UpdateCenterModal.vue src/components/update-center/UpdateCenterToolbar.vue src/__tests__/unit/update-center/UpdateCenterModal.test.ts git commit -m "fix(update-center): show loading state in the modal" ``` ### Task 3: Spark Desktop Discovery In The Main Process **Files:** - Create: `electron/main/backend/sparkInstalledApps.ts` - Modify: `electron/main/backend/install-manager.ts` - Test: `src/__tests__/unit/sparkInstalledApps.test.ts` - [ ] **Step 1: Write the failing Spark desktop-discovery tests** ```ts import { describe, expect, it, vi } from "vitest"; import { listSparkInstalledApps } from "../../../electron/main/backend/sparkInstalledApps"; describe("listSparkInstalledApps", () => { it("builds Spark installed apps from visible desktop entries", async () => { const applicationsDir = "/usr/share/applications"; const fsLike = { readdirSync: vi.fn(() => [ "reader.desktop", "hidden.desktop", "reader-alt.desktop", ]), realpathSync: vi.fn((filePath: string) => filePath), readFileSync: vi.fn((filePath: string) => { const files: Record = { [`${applicationsDir}/reader.desktop`]: "[Desktop Entry]\nName=Spark Reader\nIcon=/usr/share/pixmaps/reader.png\n", [`${applicationsDir}/hidden.desktop`]: "[Desktop Entry]\nName=Hidden Reader\nNoDisplay=true\n", [`${applicationsDir}/reader-alt.desktop`]: "[Desktop Entry]\nName=Spark Reader Alt\nIcon=reader\n", }; return files[filePath] ?? ""; }), }; const runCommand = vi.fn(async (command: string, args: string[]) => { const key = `${command} ${args.join(" ")}`; if ( key === "dpkg-query -W -f=${Package}\\t${Version}\\t${Architecture}\\n" ) { return { code: 0, stdout: "spark-reader\t1.2.3\tamd64\n", stderr: "", }; } if (key === `dpkg -S ${applicationsDir}/reader.desktop`) { return { code: 0, stdout: "spark-reader: /usr/share/applications/reader.desktop\n", stderr: "", }; } if (key === `dpkg -S ${applicationsDir}/reader-alt.desktop`) { return { code: 0, stdout: "spark-reader: /usr/share/applications/reader-alt.desktop\n", stderr: "", }; } return { code: 1, stdout: "", stderr: "not owned" }; }); const result = await listSparkInstalledApps({ applicationsDir, fsLike, runCommand, }); expect(result).toEqual({ success: true, apps: [ { pkgname: "spark-reader", name: "Spark Reader", version: "1.2.3", arch: "amd64", flags: "[installed]", origin: "spark", icon: "/usr/share/pixmaps/reader.png", isDependency: false, }, ], }); }); it("returns a failure object when package metadata lookup fails", async () => { const result = await listSparkInstalledApps({ applicationsDir: "/usr/share/applications", fsLike: { readdirSync: vi.fn(() => []), realpathSync: vi.fn((filePath: string) => filePath), readFileSync: vi.fn(() => ""), }, runCommand: vi.fn(async () => ({ code: 1, stdout: "", stderr: "dpkg-query failed", })), }); expect(result).toEqual({ success: false, message: "Failed to list installed packages", apps: [], }); }); }); ``` - [ ] **Step 2: Run the Spark desktop-discovery tests to verify they fail** Run: `npx vitest run src/__tests__/unit/sparkInstalledApps.test.ts` Expected: FAIL because `electron/main/backend/sparkInstalledApps.ts` does not exist yet. - [ ] **Step 3: Implement the Spark helper and wire it into `install-manager.ts`** ```ts // electron/main/backend/sparkInstalledApps.ts import fs from "node:fs"; import path from "node:path"; export interface SparkInstalledApp { pkgname: string; name: string; version: string; arch: string; flags: string; origin: "spark"; icon?: string; isDependency: boolean; } export interface CommandResult { code: number; stdout: string; stderr: string; } export type CommandRunner = ( command: string, args: string[], ) => Promise; interface FsLike { readdirSync: typeof fs.readdirSync; realpathSync: typeof fs.realpathSync; readFileSync: typeof fs.readFileSync; } const PACKAGE_QUERY_ARGS = [ "-W", "-f=${Package}\t${Version}\t${Architecture}\\n", ]; const parseDesktopEntry = (content: string) => ({ name: content.match(/^Name=(.+)$/m)?.[1]?.trim() ?? "", icon: content.match(/^Icon=(.+)$/m)?.[1]?.trim() ?? "", noDisplay: /^NoDisplay=true$/m.test(content), }); const parsePackageMetadata = ( stdout: string, ): Map => { const metadata = new Map(); stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .forEach((line) => { const [pkgname, version, arch] = line.split("\t"); if (!pkgname || !version || !arch) { return; } metadata.set(pkgname, { version, arch }); }); return metadata; }; const parseDpkgOwner = (stdout: string): string | null => { const firstLine = stdout .split("\n") .map((line) => line.trim()) .find((line) => line.length > 0); if (!firstLine) { return null; } const ownerField = firstLine.split(":")[0]?.split(",")[0]?.trim(); if (!ownerField) { return null; } return ownerField.replace(/:(amd64|arm64|i386|all)$/, ""); }; export const listSparkInstalledApps = async ({ applicationsDir = "/usr/share/applications", fsLike = fs, runCommand, }: { applicationsDir?: string; fsLike?: FsLike; runCommand: CommandRunner; }): Promise< | { success: true; apps: SparkInstalledApp[] } | { success: false; message: string; apps: [] } > => { const metadataResult = await runCommand("dpkg-query", PACKAGE_QUERY_ARGS); if (metadataResult.code !== 0) { return { success: false, message: "Failed to list installed packages", apps: [], }; } const packageMetadata = parsePackageMetadata(metadataResult.stdout); const appsByPackage = new Map(); const desktopFiles = fsLike .readdirSync(applicationsDir) .filter((entry) => entry.endsWith(".desktop")) .sort(); for (const desktopFile of desktopFiles) { const desktopPath = path.join(applicationsDir, desktopFile); try { const resolvedDesktopPath = fsLike.realpathSync(desktopPath).toString(); const content = fsLike.readFileSync(resolvedDesktopPath, "utf-8"); const entry = parseDesktopEntry(content.toString()); if (entry.noDisplay) { continue; } const ownerResult = await runCommand("dpkg", ["-S", resolvedDesktopPath]); if (ownerResult.code !== 0) { continue; } const pkgname = parseDpkgOwner(ownerResult.stdout); if (!pkgname || appsByPackage.has(pkgname)) { continue; } const metadata = packageMetadata.get(pkgname); if (!metadata) { continue; } appsByPackage.set(pkgname, { pkgname, name: entry.name || pkgname, version: metadata.version, arch: metadata.arch, flags: "[installed]", origin: "spark", icon: entry.icon || undefined, isDependency: false, }); } catch { continue; } } return { success: true, apps: [...appsByPackage.values()].sort((left, right) => left.pkgname.localeCompare(right.pkgname), ), }; }; ``` ```ts // electron/main/backend/install-manager.ts import { listSparkInstalledApps } from "./sparkInstalledApps"; ipcMain.handle( "list-installed", async (_event, origin: "apm" | "spark" = "apm") => { const apmBasePath = "/var/lib/apm/apm/files/ace-env/var/lib/apm"; try { const installedApps: Array<{ pkgname: string; name: string; version: string; arch: string; flags: string; origin: "spark" | "apm"; icon?: string; isDependency: boolean; }> = []; if (origin === "spark") { return await listSparkInstalledApps({ runCommand: runCommandCapture }); } const { code, stdout } = await runCommandCapture("apm", [ "list", "--installed", ]); if (code !== 0) { logger.warn(`Failed to list installed packages: ${stdout}`); return { success: false, message: "Failed to list installed packages", apps: [], }; } const cleanStdout = stdout.replace(/\x1b\[[0-9;]*m/g, ""); const lines = cleanStdout.split("\n"); for (const line of lines) { const trimmed = line.trim(); if ( !trimmed || trimmed.startsWith("Listing") || trimmed.startsWith("[INFO]") || trimmed.startsWith("警告") ) { continue; } const match = trimmed.match( /^(\S+)\/\S+(?:,\S+)?\s+(\S+)\s+(\S+)\s+\[(.+)\]$/, ); if (!match) { logger.debug(`Failed to parse line: ${trimmed}`); continue; } const [, pkgname, version, arch, flags] = match; let appName = pkgname; let icon = ""; const pkgPath = path.join(apmBasePath, pkgname); const entriesPath = path.join(pkgPath, "entries", "applications"); const hasEntries = fs.existsSync(entriesPath); if (hasEntries) { try { const desktopFiles = fs.readdirSync(entriesPath); for (const file of desktopFiles) { if (!file.endsWith(".desktop")) { continue; } const desktopPath = path.join(entriesPath, file); const content = fs.readFileSync(desktopPath, "utf-8"); const nameMatch = content.match(/^Name=(.+)$/m); const iconMatch = content.match(/^Icon=(.+)$/m); if (nameMatch) appName = nameMatch[1].trim(); if (iconMatch) icon = iconMatch[1].trim(); break; } } catch (error) { logger.warn(`Failed to read desktop file for ${pkgname}: ${error}`); } } installedApps.push({ pkgname, name: appName, version, arch, flags, origin: "apm", icon: icon || undefined, isDependency: !hasEntries, }); } installedApps.sort((left, right) => left.pkgname.localeCompare(right.pkgname), ); return { success: true, apps: installedApps }; } catch (error) { logger.error( `list-installed failed: ${error instanceof Error ? error.message : String(error)}`, ); return { success: false, message: error instanceof Error ? error.message : String(error), apps: [], }; } }, ); ``` - [ ] **Step 4: Re-run the Spark helper tests** Run: `npx vitest run src/__tests__/unit/sparkInstalledApps.test.ts` Expected: PASS with the desktop-discovery and metadata-failure cases green. - [ ] **Step 5: Commit the Spark discovery change** ```bash git add electron/main/backend/sparkInstalledApps.ts electron/main/backend/install-manager.ts src/__tests__/unit/sparkInstalledApps.test.ts git commit -m "fix(installed-apps): discover spark apps from desktop files" ``` ### Task 4: Installed App Normalization And Open Action **Files:** - Create: `src/modules/installedApps.ts` - Modify: `src/App.vue` - Modify: `src/components/InstalledAppsModal.vue` - Create: `src/__tests__/unit/installedApps.test.ts` - Modify: `src/__tests__/unit/InstalledAppsModal.test.ts` - [ ] **Step 1: Write the failing installed-app normalization and open-action tests** ```ts // src/__tests__/unit/installedApps.test.ts import { describe, expect, it } from "vitest"; import { buildInstalledApps } from "@/modules/installedApps"; import type { App, InstalledAppInfo } from "@/global/typedefinition"; const createInstalled = ( overrides: Partial = {}, ): InstalledAppInfo => ({ pkgname: "spark-reader", name: "Spark Reader", version: "1.0.0", arch: "amd64", flags: "[installed]", origin: "spark", icon: "/usr/share/pixmaps/reader.png", isDependency: false, ...overrides, }); const createCatalogApp = (overrides: Partial = {}): App => ({ name: "Spark Reader", pkgname: "spark-reader", version: "1.0.0", category: "office", tags: "", more: "", filename: "", torrent_address: "", author: "", contributor: "", website: "", update: "", size: "", img_urls: [], icons: "remote-icon.png", origin: "spark", currentStatus: "not-installed", ...overrides, }); describe("buildInstalledApps", () => { it("keeps Spark desktop apps even when they are missing from the remote catalog", () => { const result = buildInstalledApps({ installed: [createInstalled()], catalogApps: [], origin: "spark", }); expect(result).toMatchObject([ { name: "Spark Reader", pkgname: "spark-reader", category: "unknown", origin: "spark", currentStatus: "installed", icons: "/usr/share/pixmaps/reader.png", }, ]); }); it("reuses catalog metadata when the package exists in the selected origin", () => { const result = buildInstalledApps({ installed: [createInstalled({ origin: "apm", pkgname: "spark-clock" })], catalogApps: [ createCatalogApp({ name: "Spark Clock", pkgname: "spark-clock", category: "utilities", origin: "apm", }), ], origin: "apm", }); expect(result[0]).toMatchObject({ name: "Spark Clock", pkgname: "spark-clock", category: "utilities", origin: "apm", currentStatus: "installed", }); }); }); ``` ```ts // src/__tests__/unit/InstalledAppsModal.test.ts import { fireEvent, render, screen } from "@testing-library/vue"; import { describe, expect, it } from "vitest"; import InstalledAppsModal from "@/components/InstalledAppsModal.vue"; import type { App } from "@/global/typedefinition"; const createInstalledApp = (): App => ({ name: "Spark Reader", pkgname: "spark-reader", version: "1.0.0", category: "unknown", tags: "", more: "", filename: "", torrent_address: "", author: "", contributor: "", website: "", update: "", size: "", img_urls: [], icons: "/usr/share/pixmaps/reader.png", origin: "spark", currentStatus: "installed", arch: "amd64", flags: "[installed]", isDependency: false, }); describe("InstalledAppsModal", () => { it("keeps scroll chaining inside the modal list", () => { const { container } = render(InstalledAppsModal, { props: { show: true, apps: [], loading: false, error: "", activeOrigin: "spark", storeFilter: "both", apmAvailable: true, }, }); expect(screen.getByText("已安装应用")).toBeTruthy(); const scrollContainer = container.querySelector(".overflow-y-auto"); expect(scrollContainer?.className).toContain("overscroll-contain"); }); it("emits open-app with pkgname and origin", async () => { const { emitted } = render(InstalledAppsModal, { props: { show: true, apps: [createInstalledApp()], loading: false, error: "", activeOrigin: "spark", storeFilter: "both", apmAvailable: true, }, }); await fireEvent.click(screen.getByRole("button", { name: "打开" })); expect(emitted()["open-app"]).toEqual([["spark-reader", "spark"]]); }); }); ``` - [ ] **Step 2: Run the installed-app tests to verify they fail** Run: `npx vitest run src/__tests__/unit/installedApps.test.ts src/__tests__/unit/InstalledAppsModal.test.ts` Expected: FAIL because `src/modules/installedApps.ts` does not exist yet, and `InstalledAppsModal.vue` does not emit `open-app`. - [ ] **Step 3: Implement normalization, remove the Spark catalog filter, and add the open button** ```ts // src/modules/installedApps.ts import type { App, InstalledAppInfo } from "@/global/typedefinition"; export const buildInstalledApps = ({ installed, catalogApps, origin, }: { installed: InstalledAppInfo[]; catalogApps: App[]; origin: "spark" | "apm"; }): App[] => { return installed.map((app) => { const catalogApp = catalogApps.find( (item) => item.pkgname === app.pkgname && item.origin === origin, ); if (catalogApp) { return { ...catalogApp, flags: app.flags, arch: app.arch, currentStatus: "installed" as const, isDependency: app.isDependency, }; } 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, currentStatus: "installed" as const, arch: app.arch, flags: app.flags, isDependency: app.isDependency, }; }); }; ``` ```vue ``` ```ts // src/App.vue import { buildInstalledApps } from "./modules/installedApps"; const refreshInstalledApps = async () => { installedLoading.value = true; installedError.value = ""; try { const origin = activeInstalledOrigin.value; const result = await window.ipcRenderer.invoke("list-installed", origin); if (!result?.success) { installedApps.value = []; installedError.value = result?.message || "读取已安装应用失败"; return; } installedApps.value = buildInstalledApps({ installed: result.apps, catalogApps: apps.value, origin, }); } catch (error: unknown) { installedApps.value = []; installedError.value = (error as Error)?.message || "读取已安装应用失败"; } finally { installedLoading.value = false; } }; ``` - [ ] **Step 4: Re-run the installed-app tests** Run: `npx vitest run src/__tests__/unit/installedApps.test.ts src/__tests__/unit/InstalledAppsModal.test.ts` Expected: PASS with Spark fallback-card coverage and the `open-app` emission check green. - [ ] **Step 5: Commit the installed-app UI change** ```bash git add src/modules/installedApps.ts src/App.vue src/components/InstalledAppsModal.vue src/__tests__/unit/installedApps.test.ts src/__tests__/unit/InstalledAppsModal.test.ts git commit -m "feat(installed-apps): open local apps from software manager" ``` ## Final Verification Run these commands after the four tasks above are complete: 1. `npx vitest run src/__tests__/unit/update-center/store.test.ts src/__tests__/unit/update-center/UpdateCenterModal.test.ts src/__tests__/unit/sparkInstalledApps.test.ts src/__tests__/unit/installedApps.test.ts src/__tests__/unit/InstalledAppsModal.test.ts` 2. `npm run lint` 3. `npm run build:vite` Then do one manual smoke pass in the running app: 1. Open the update center and confirm the modal appears immediately with `正在检查更新…` before the first snapshot arrives. 2. Trigger update-center refresh and confirm the refresh button is disabled while `正在刷新更新列表…` is visible. 3. Open the installed-apps modal with `Spark 软件` selected and confirm desktop apps from `/usr/share/applications` appear even when they are absent from the remote store catalog. 4. Click `打开` on one `spark` entry and one `apm` entry and confirm each one goes through the existing `launch-app` IPC path.