fix(sync): isolate installed sync state

This commit is contained in:
2026-05-19 02:03:29 +08:00
parent 753f91e837
commit 34551fce7b
6 changed files with 269 additions and 12 deletions
+91 -4
View File
@@ -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<App[]>([]);
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<void> => {
};
const refreshInstalledSyncCandidates = async (): Promise<void> => {
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<void> => {
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(
@@ -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<SyncedAppList>();
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([]));
});
});
@@ -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);
});
});
+20 -1
View File
@@ -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> = {}): 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" }),
]);
});
});
+19 -6
View File
@@ -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<boolean | null>(readSyncEnabled());
export const installedSyncEnabled = ref<boolean | null>(
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));
});
+19
View File
@@ -28,3 +28,22 @@ export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => {
export const cloudItemKey = (
item: Pick<SyncedAppListItem, "origin" | "pkgname">,
): 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,
];
};