diff --git a/src/App.vue b/src/App.vue index e9d49cb9..8a5ad7e8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -295,6 +295,7 @@ import { } from "./global/downloadStatus"; import { installedSyncEnabled, + loadInstalledSyncPreference, setInstalledSyncEnabled, } from "./global/accountSyncState"; import { @@ -336,7 +337,11 @@ import { parsePackageArch, } from "./modules/appIdentity"; import { resolveFavoriteItems } from "./modules/favoriteAvailability"; -import { buildSyncItems, cloudItemKey } from "./modules/appListSync"; +import { + buildSyncItems, + cloudItemKey, + mergeInstalledApps, +} from "./modules/appListSync"; import type { App, AppJson, @@ -441,6 +446,8 @@ const downloadedLoading = ref(false); const downloadedError = ref(""); const downloadedRequestGeneration = ref(0); const syncLoading = ref(false); +const syncRequestGeneration = ref(0); +const syncCandidateApps = ref([]); const restoreLoading = ref(false); const restoreError = ref(""); const showRestoreModal = ref(false); @@ -1477,6 +1484,10 @@ const handleLogout = () => { clearFavoriteState(); clearDownloadedState(); clearRestoreState(); + syncRequestGeneration.value += 1; + syncLoading.value = false; + syncCandidateApps.value = []; + loadInstalledSyncPreference(null); showLoginModal.value = false; showLoginPrompt.value = false; isSidebarOpen.value = false; @@ -1498,6 +1509,7 @@ const handleFlarumLogin = async (payload: FlarumLoginPayload) => { flarumToken: flarumToken.token, }); setAuthSession(session); + loadInstalledSyncPreference(session.user.id); showLoginModal.value = false; } catch (error: unknown) { loginError.value = (error as Error)?.message || "登录失败,请稍后重试"; @@ -1530,25 +1542,87 @@ const loadDownloadedHistory = async (): Promise => { }; const refreshInstalledSyncCandidates = async (): Promise => { - await refreshFavoriteInstalledApps(); + 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); + } + }), + ); + + syncCandidateApps.value = mergeInstalledApps( + syncCandidateApps.value, + refreshedApps, + origins, + ); }; const syncInstalledAppsToAccount = async (): Promise => { if (!requireLogin("云端同步需要登录星火账号。")) return; + if (syncLoading.value) return; + const userId = currentUser.value?.id; + if (userId === undefined) return; + const generation = syncRequestGeneration.value + 1; + syncRequestGeneration.value = generation; syncLoading.value = true; try { await refreshInstalledSyncCandidates(); - const items = buildSyncItems(installedApps.value); + if ( + syncRequestGeneration.value !== generation || + currentUser.value?.id !== userId + ) { + return; + } + const items = buildSyncItems(syncCandidateApps.value); await uploadSyncedAppList({ clientArch: window.apm_store.arch || "amd64", distro: systemInfo.value.distro, items, }); + if ( + syncRequestGeneration.value !== generation || + currentUser.value?.id !== userId + ) { + return; + } downloadedError.value = ""; } catch (error: unknown) { + if ( + syncRequestGeneration.value !== generation || + currentUser.value?.id !== userId + ) { + return; + } downloadedError.value = (error as Error)?.message || "同步已安装应用失败"; } finally { - syncLoading.value = false; + if ( + syncRequestGeneration.value === generation && + currentUser.value?.id === userId + ) { + syncLoading.value = false; + } } }; @@ -2215,6 +2289,19 @@ onUnmounted(() => { }); // 观察器 +watch( + () => currentUser.value?.id ?? null, + (userId, previousUserId) => { + loadInstalledSyncPreference(userId); + if (previousUserId !== undefined && userId !== previousUserId) { + syncRequestGeneration.value += 1; + syncLoading.value = false; + syncCandidateApps.value = []; + } + }, + { immediate: true }, +); + watch(themeMode, (newVal) => { localStorage.setItem("theme", newVal); window.ipcRenderer.send( diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts index 932d6d61..bcea8572 100644 --- a/src/__tests__/unit/App.account-placeholders.test.ts +++ b/src/__tests__/unit/App.account-placeholders.test.ts @@ -1,4 +1,10 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/vue"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/vue"; import { beforeEach, describe, expect, it, vi } from "vitest"; import App from "@/App.vue"; @@ -6,12 +12,14 @@ import { fetchSyncedAppList, listDownloadedApps, listFavoriteFolders, + uploadSyncedAppList, } from "@/modules/backendApi"; import { setAuthSession } from "@/global/authState"; import type { DownloadedAppList, FavoriteFolder, FavoriteItem, + SyncedAppList, } from "@/global/typedefinition"; const invoke = vi.fn(); @@ -56,6 +64,14 @@ const downloadedList = ( pageSize: 50, }); +const syncedList = (items: SyncedAppList["items"]): SyncedAppList => ({ + snapshotName: "默认列表", + clientArch: "amd64", + distro: "deepin 25", + updatedAt: "2026-05-18T00:00:00Z", + items, +}); + const setSecondUserSession = () => { setAuthSession({ accessToken: "backend-token-b", @@ -153,6 +169,7 @@ vi.mock("@/modules/backendApi", () => ({ describe("App account placeholders", () => { beforeEach(() => { + vi.clearAllMocks(); invoke.mockReset(); invoke.mockImplementation(async (channel: string) => { if (channel === "get-store-filter") return "both"; @@ -492,4 +509,78 @@ describe("App account placeholders", () => { ); }); }); + + it("keeps the installed modal scoped to the active origin after sync", 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 === "get-system-info") return { distro: "deepin 25" }; + 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 []; + }); + vi.mocked(uploadSyncedAppList).mockResolvedValueOnce(syncedList([])); + render(App); + + await fireEvent.click(await screen.findByText("应用管理")); + const modal = screen.getByText("已安装应用").closest(".fixed"); + if (!(modal instanceof HTMLElement)) throw new Error("modal not found"); + expect(within(modal).getByText("暂无已安装应用")).toBeTruthy(); + + await fireEvent.click( + within(modal).getByRole("button", { name: /同步到账号/ }), + ); + + await waitFor(() => { + expect(uploadSyncedAppList).toHaveBeenCalled(); + }); + expect(within(modal).queryByText("WPS")).toBeNull(); + expect(within(modal).getByText("暂无已安装应用")).toBeTruthy(); + }); + + it("ignores overlapping installed sync requests", async () => { + const syncUpload = createDeferred(); + vi.mocked(uploadSyncedAppList).mockReturnValue(syncUpload.promise); + invoke.mockImplementation(async (channel: string) => { + if (channel === "get-store-filter") return "apm"; + if (channel === "check-spark-available") return false; + if (channel === "check-apm-available") return true; + if (channel === "get-app-version") return "5.0.0"; + if (channel === "get-system-info") return { distro: "deepin 25" }; + if (channel === "list-installed") return { success: true, apps: [] }; + return []; + }); + render(App); + + await fireEvent.click(await screen.findByRole("button", { name: /Momen/ })); + await fireEvent.click(screen.getByText("用户管理")); + await fireEvent.click( + await screen.findByRole("button", { name: "立即同步" }), + ); + await fireEvent.click(screen.getByRole("button", { name: "立即同步" })); + + await waitFor(() => { + expect(uploadSyncedAppList).toHaveBeenCalledTimes(1); + }); + syncUpload.resolve(syncedList([])); + }); }); diff --git a/src/__tests__/unit/accountSyncState.test.ts b/src/__tests__/unit/accountSyncState.test.ts new file mode 100644 index 00000000..8d7b2fd8 --- /dev/null +++ b/src/__tests__/unit/accountSyncState.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("accountSyncState", () => { + beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + }); + + it("scopes installed sync preference to the current user", async () => { + const { + installedSyncEnabled, + loadInstalledSyncPreference, + setInstalledSyncEnabled, + } = await import("@/global/accountSyncState"); + + loadInstalledSyncPreference(1); + setInstalledSyncEnabled(true); + + loadInstalledSyncPreference(2); + + expect(installedSyncEnabled.value).toBeNull(); + + setInstalledSyncEnabled(false); + loadInstalledSyncPreference(1); + + expect(installedSyncEnabled.value).toBe(true); + }); +}); diff --git a/src/__tests__/unit/appListSync.test.ts b/src/__tests__/unit/appListSync.test.ts index 988e438b..c567339d 100644 --- a/src/__tests__/unit/appListSync.test.ts +++ b/src/__tests__/unit/appListSync.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildSyncItems, cloudItemKey } from "@/modules/appListSync"; +import { + buildSyncItems, + cloudItemKey, + mergeInstalledApps, +} from "@/modules/appListSync"; import type { App } from "@/global/typedefinition"; const createApp = (overrides: Partial = {}): App => ({ @@ -71,4 +75,19 @@ describe("appListSync", () => { "apm:amber-ce", ); }); + + it("merges refreshed apps without mutating active modal origin lists", () => { + const current = [createApp({ origin: "apm", pkgname: "apm-installed" })]; + const refreshed = [ + createApp({ origin: "spark", pkgname: "spark-installed" }), + ]; + + expect(mergeInstalledApps(current, refreshed, ["spark"])).toEqual([ + expect.objectContaining({ origin: "apm", pkgname: "apm-installed" }), + expect.objectContaining({ origin: "spark", pkgname: "spark-installed" }), + ]); + expect(current).toEqual([ + expect.objectContaining({ origin: "apm", pkgname: "apm-installed" }), + ]); + }); }); diff --git a/src/global/accountSyncState.ts b/src/global/accountSyncState.ts index acb0846e..7b7d2958 100644 --- a/src/global/accountSyncState.ts +++ b/src/global/accountSyncState.ts @@ -1,26 +1,39 @@ import { ref, watch } from "vue"; const INSTALLED_SYNC_STORAGE_KEY = "spark-store-installed-sync-enabled"; +let activeSyncUserId: number | null = null; -const readSyncEnabled = (): boolean | null => { - const savedValue = localStorage.getItem(INSTALLED_SYNC_STORAGE_KEY); +const syncStorageKey = (userId: number | null): string => + userId === null + ? INSTALLED_SYNC_STORAGE_KEY + : `${INSTALLED_SYNC_STORAGE_KEY}:${userId}`; + +const readSyncEnabled = (userId: number | null): boolean | null => { + const savedValue = localStorage.getItem(syncStorageKey(userId)); if (savedValue === "true") return true; if (savedValue === "false") return false; return null; }; -export const installedSyncEnabled = ref(readSyncEnabled()); +export const installedSyncEnabled = ref( + readSyncEnabled(activeSyncUserId), +); + +export const loadInstalledSyncPreference = (userId: number | null): void => { + activeSyncUserId = userId; + installedSyncEnabled.value = readSyncEnabled(userId); +}; export const setInstalledSyncEnabled = (enabled: boolean): void => { installedSyncEnabled.value = enabled; - localStorage.setItem(INSTALLED_SYNC_STORAGE_KEY, String(enabled)); + localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled)); }; watch(installedSyncEnabled, (enabled) => { if (enabled === null) { - localStorage.removeItem(INSTALLED_SYNC_STORAGE_KEY); + localStorage.removeItem(syncStorageKey(activeSyncUserId)); return; } - localStorage.setItem(INSTALLED_SYNC_STORAGE_KEY, String(enabled)); + localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled)); }); diff --git a/src/modules/appListSync.ts b/src/modules/appListSync.ts index 6e26f78a..86d144de 100644 --- a/src/modules/appListSync.ts +++ b/src/modules/appListSync.ts @@ -28,3 +28,22 @@ export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => { export const cloudItemKey = ( item: Pick, ): string => `${item.origin}:${item.pkgname}`; + +export const mergeInstalledApps = ( + currentApps: App[], + refreshedApps: App[], + refreshedOrigins: Array<"spark" | "apm">, +): App[] => { + const refreshedKeys = new Set( + refreshedApps.map((app) => `${app.origin}:${app.pkgname}`), + ); + + return [ + ...currentApps.filter( + (app) => + !refreshedOrigins.includes(app.origin) && + !refreshedKeys.has(`${app.origin}:${app.pkgname}`), + ), + ...refreshedApps, + ]; +};