mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 14:13:49 +08:00
fix(sync): isolate installed sync state
This commit is contained in:
+91
-4
@@ -295,6 +295,7 @@ import {
|
|||||||
} from "./global/downloadStatus";
|
} from "./global/downloadStatus";
|
||||||
import {
|
import {
|
||||||
installedSyncEnabled,
|
installedSyncEnabled,
|
||||||
|
loadInstalledSyncPreference,
|
||||||
setInstalledSyncEnabled,
|
setInstalledSyncEnabled,
|
||||||
} from "./global/accountSyncState";
|
} from "./global/accountSyncState";
|
||||||
import {
|
import {
|
||||||
@@ -336,7 +337,11 @@ import {
|
|||||||
parsePackageArch,
|
parsePackageArch,
|
||||||
} from "./modules/appIdentity";
|
} from "./modules/appIdentity";
|
||||||
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
|
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
|
||||||
import { buildSyncItems, cloudItemKey } from "./modules/appListSync";
|
import {
|
||||||
|
buildSyncItems,
|
||||||
|
cloudItemKey,
|
||||||
|
mergeInstalledApps,
|
||||||
|
} from "./modules/appListSync";
|
||||||
import type {
|
import type {
|
||||||
App,
|
App,
|
||||||
AppJson,
|
AppJson,
|
||||||
@@ -441,6 +446,8 @@ const downloadedLoading = ref(false);
|
|||||||
const downloadedError = ref("");
|
const downloadedError = ref("");
|
||||||
const downloadedRequestGeneration = ref(0);
|
const downloadedRequestGeneration = ref(0);
|
||||||
const syncLoading = ref(false);
|
const syncLoading = ref(false);
|
||||||
|
const syncRequestGeneration = ref(0);
|
||||||
|
const syncCandidateApps = ref<App[]>([]);
|
||||||
const restoreLoading = ref(false);
|
const restoreLoading = ref(false);
|
||||||
const restoreError = ref("");
|
const restoreError = ref("");
|
||||||
const showRestoreModal = ref(false);
|
const showRestoreModal = ref(false);
|
||||||
@@ -1477,6 +1484,10 @@ const handleLogout = () => {
|
|||||||
clearFavoriteState();
|
clearFavoriteState();
|
||||||
clearDownloadedState();
|
clearDownloadedState();
|
||||||
clearRestoreState();
|
clearRestoreState();
|
||||||
|
syncRequestGeneration.value += 1;
|
||||||
|
syncLoading.value = false;
|
||||||
|
syncCandidateApps.value = [];
|
||||||
|
loadInstalledSyncPreference(null);
|
||||||
showLoginModal.value = false;
|
showLoginModal.value = false;
|
||||||
showLoginPrompt.value = false;
|
showLoginPrompt.value = false;
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
@@ -1498,6 +1509,7 @@ const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
|
|||||||
flarumToken: flarumToken.token,
|
flarumToken: flarumToken.token,
|
||||||
});
|
});
|
||||||
setAuthSession(session);
|
setAuthSession(session);
|
||||||
|
loadInstalledSyncPreference(session.user.id);
|
||||||
showLoginModal.value = false;
|
showLoginModal.value = false;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
loginError.value = (error as Error)?.message || "登录失败,请稍后重试";
|
loginError.value = (error as Error)?.message || "登录失败,请稍后重试";
|
||||||
@@ -1530,25 +1542,87 @@ const loadDownloadedHistory = async (): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refreshInstalledSyncCandidates = 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> => {
|
const syncInstalledAppsToAccount = async (): Promise<void> => {
|
||||||
if (!requireLogin("云端同步需要登录星火账号。")) return;
|
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;
|
syncLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await refreshInstalledSyncCandidates();
|
await refreshInstalledSyncCandidates();
|
||||||
const items = buildSyncItems(installedApps.value);
|
if (
|
||||||
|
syncRequestGeneration.value !== generation ||
|
||||||
|
currentUser.value?.id !== userId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = buildSyncItems(syncCandidateApps.value);
|
||||||
await uploadSyncedAppList({
|
await uploadSyncedAppList({
|
||||||
clientArch: window.apm_store.arch || "amd64",
|
clientArch: window.apm_store.arch || "amd64",
|
||||||
distro: systemInfo.value.distro,
|
distro: systemInfo.value.distro,
|
||||||
items,
|
items,
|
||||||
});
|
});
|
||||||
|
if (
|
||||||
|
syncRequestGeneration.value !== generation ||
|
||||||
|
currentUser.value?.id !== userId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
downloadedError.value = "";
|
downloadedError.value = "";
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
syncRequestGeneration.value !== generation ||
|
||||||
|
currentUser.value?.id !== userId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
|
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
|
||||||
} finally {
|
} 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) => {
|
watch(themeMode, (newVal) => {
|
||||||
localStorage.setItem("theme", newVal);
|
localStorage.setItem("theme", newVal);
|
||||||
window.ipcRenderer.send(
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import App from "@/App.vue";
|
import App from "@/App.vue";
|
||||||
@@ -6,12 +12,14 @@ import {
|
|||||||
fetchSyncedAppList,
|
fetchSyncedAppList,
|
||||||
listDownloadedApps,
|
listDownloadedApps,
|
||||||
listFavoriteFolders,
|
listFavoriteFolders,
|
||||||
|
uploadSyncedAppList,
|
||||||
} from "@/modules/backendApi";
|
} from "@/modules/backendApi";
|
||||||
import { setAuthSession } from "@/global/authState";
|
import { setAuthSession } from "@/global/authState";
|
||||||
import type {
|
import type {
|
||||||
DownloadedAppList,
|
DownloadedAppList,
|
||||||
FavoriteFolder,
|
FavoriteFolder,
|
||||||
FavoriteItem,
|
FavoriteItem,
|
||||||
|
SyncedAppList,
|
||||||
} from "@/global/typedefinition";
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
const invoke = vi.fn();
|
const invoke = vi.fn();
|
||||||
@@ -56,6 +64,14 @@ const downloadedList = (
|
|||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const syncedList = (items: SyncedAppList["items"]): SyncedAppList => ({
|
||||||
|
snapshotName: "默认列表",
|
||||||
|
clientArch: "amd64",
|
||||||
|
distro: "deepin 25",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
|
||||||
const setSecondUserSession = () => {
|
const setSecondUserSession = () => {
|
||||||
setAuthSession({
|
setAuthSession({
|
||||||
accessToken: "backend-token-b",
|
accessToken: "backend-token-b",
|
||||||
@@ -153,6 +169,7 @@ vi.mock("@/modules/backendApi", () => ({
|
|||||||
|
|
||||||
describe("App account placeholders", () => {
|
describe("App account placeholders", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
invoke.mockReset();
|
invoke.mockReset();
|
||||||
invoke.mockImplementation(async (channel: string) => {
|
invoke.mockImplementation(async (channel: string) => {
|
||||||
if (channel === "get-store-filter") return "both";
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
import type { App } from "@/global/typedefinition";
|
||||||
|
|
||||||
const createApp = (overrides: Partial<App> = {}): App => ({
|
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||||
@@ -71,4 +75,19 @@ describe("appListSync", () => {
|
|||||||
"apm:amber-ce",
|
"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" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
const INSTALLED_SYNC_STORAGE_KEY = "spark-store-installed-sync-enabled";
|
const INSTALLED_SYNC_STORAGE_KEY = "spark-store-installed-sync-enabled";
|
||||||
|
let activeSyncUserId: number | null = null;
|
||||||
|
|
||||||
const readSyncEnabled = (): boolean | null => {
|
const syncStorageKey = (userId: number | null): string =>
|
||||||
const savedValue = localStorage.getItem(INSTALLED_SYNC_STORAGE_KEY);
|
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 === "true") return true;
|
||||||
if (savedValue === "false") return false;
|
if (savedValue === "false") return false;
|
||||||
return null;
|
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 => {
|
export const setInstalledSyncEnabled = (enabled: boolean): void => {
|
||||||
installedSyncEnabled.value = enabled;
|
installedSyncEnabled.value = enabled;
|
||||||
localStorage.setItem(INSTALLED_SYNC_STORAGE_KEY, String(enabled));
|
localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled));
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(installedSyncEnabled, (enabled) => {
|
watch(installedSyncEnabled, (enabled) => {
|
||||||
if (enabled === null) {
|
if (enabled === null) {
|
||||||
localStorage.removeItem(INSTALLED_SYNC_STORAGE_KEY);
|
localStorage.removeItem(syncStorageKey(activeSyncUserId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(INSTALLED_SYNC_STORAGE_KEY, String(enabled));
|
localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,3 +28,22 @@ export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => {
|
|||||||
export const cloudItemKey = (
|
export const cloudItemKey = (
|
||||||
item: Pick<SyncedAppListItem, "origin" | "pkgname">,
|
item: Pick<SyncedAppListItem, "origin" | "pkgname">,
|
||||||
): string => `${item.origin}:${item.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,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user