feat(sync): add installed app cloud sync

This commit is contained in:
2026-05-19 01:43:28 +08:00
parent ac1f46bd73
commit acffb6c5ee
7 changed files with 570 additions and 3 deletions
+130 -2
View File
@@ -170,12 +170,27 @@
:store-filter="storeFilter"
:spark-available="sparkAvailable"
:apm-available="apmAvailable"
:logged-in="isLoggedIn"
:syncing="syncLoading"
@close="closeInstalledModal"
@refresh="refreshInstalledApps"
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
@open-detail="openDetail"
@uninstall="uninstallInstalledApp"
@switch-origin="handleSwitchOrigin"
@sync-to-account="syncInstalledAppsToAccount"
@restore-from-account="openRestoreFromAccount"
@request-login="requireLogin('云端同步需要登录星火账号')"
/>
<AppListRestoreModal
:show="showRestoreModal"
:loading="restoreLoading"
:error="restoreError"
:items="restoreItems"
:installed-keys="installedCloudKeys"
@close="showRestoreModal = false"
@install-selected="installCloudItems"
/>
<UpdateCenterModal
@@ -249,6 +264,7 @@ import ScreenPreview from "./components/ScreenPreview.vue";
import DownloadQueue from "./components/DownloadQueue.vue";
import DownloadDetail from "./components/DownloadDetail.vue";
import InstalledAppsModal from "./components/InstalledAppsModal.vue";
import AppListRestoreModal from "./components/AppListRestoreModal.vue";
import UpdateCenterModal from "./components/UpdateCenterModal.vue";
import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
import ApmInstallConfirmModal from "./components/ApmInstallConfirmModal.vue";
@@ -291,10 +307,12 @@ import {
bulkDeleteFavoriteItems,
createFavoriteFolder,
exchangeFlarumToken,
fetchSyncedAppList,
listDownloadedApps,
listFavoriteFolders,
listFavoriteItems,
recordDownloadedApp,
uploadSyncedAppList,
} from "./modules/backendApi";
import { requestFlarumToken } from "./modules/flarumAuth";
import {
@@ -318,6 +336,7 @@ import {
parsePackageArch,
} from "./modules/appIdentity";
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
import { buildSyncItems, cloudItemKey } from "./modules/appListSync";
import type {
App,
AppJson,
@@ -337,6 +356,7 @@ import type {
ResolvedFavoriteItem,
SystemInfo,
DownloadedAppRecord,
SyncedAppListItem,
} from "./global/typedefinition";
import type { Ref } from "vue";
import type { IpcRendererEvent } from "electron";
@@ -420,6 +440,13 @@ const downloadedApps = ref<DownloadedAppRecord[]>([]);
const downloadedLoading = ref(false);
const downloadedError = ref("");
const downloadedRequestGeneration = ref(0);
const syncLoading = ref(false);
const restoreLoading = ref(false);
const restoreError = ref("");
const showRestoreModal = ref(false);
const restoreItems = ref<SyncedAppListItem[]>([]);
const restoreRequestGeneration = ref(0);
const installedSyncPromptShown = ref(false);
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
type PendingDownloadRecord = Omit<
DownloadedAppRecord,
@@ -540,6 +567,10 @@ const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
),
);
const installedCloudKeys = computed(
() => new Set(installedApps.value.map((app) => cloudItemKey(app))),
);
// 方法
const syncThemePreference = () => {
document.documentElement.classList.toggle("dark", isDarkTheme.value);
@@ -1418,6 +1449,14 @@ const clearDownloadedState = () => {
downloadedError.value = "";
};
const clearRestoreState = () => {
restoreRequestGeneration.value += 1;
restoreItems.value = [];
restoreLoading.value = false;
restoreError.value = "";
showRestoreModal.value = false;
};
const isCurrentFavoriteRequest = (generation: number): boolean =>
favoriteRequestGeneration.value === generation && isLoggedIn.value;
@@ -1428,11 +1467,16 @@ const isCurrentDownloadedRequest = (
downloadedRequestGeneration.value === generation &&
currentUser.value?.id === userId;
const isCurrentRestoreRequest = (generation: number, userId: number): boolean =>
restoreRequestGeneration.value === generation &&
currentUser.value?.id === userId;
const handleLogout = () => {
logout();
pendingDownloadRecords.clear();
clearFavoriteState();
clearDownloadedState();
clearRestoreState();
showLoginModal.value = false;
showLoginPrompt.value = false;
isSidebarOpen.value = false;
@@ -1485,8 +1529,91 @@ const loadDownloadedHistory = async (): Promise<void> => {
}
};
const syncInstalledAppsNow = () => {
logger.warn("已安装应用同步将在后续任务中启用");
const refreshInstalledSyncCandidates = async (): Promise<void> => {
await refreshFavoriteInstalledApps();
};
const syncInstalledAppsToAccount = async (): Promise<void> => {
if (!requireLogin("云端同步需要登录星火账号。")) return;
syncLoading.value = true;
try {
await refreshInstalledSyncCandidates();
const items = buildSyncItems(installedApps.value);
await uploadSyncedAppList({
clientArch: window.apm_store.arch || "amd64",
distro: systemInfo.value.distro,
items,
});
downloadedError.value = "";
} catch (error: unknown) {
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
} finally {
syncLoading.value = false;
}
};
const syncInstalledAppsNow = (): void => {
void syncInstalledAppsToAccount();
};
const openRestoreFromAccount = async (): Promise<void> => {
if (!requireLogin("云端同步需要登录星火账号。")) return;
const userId = currentUser.value?.id;
if (userId === undefined) return;
const generation = restoreRequestGeneration.value + 1;
restoreRequestGeneration.value = generation;
showRestoreModal.value = true;
restoreLoading.value = true;
restoreError.value = "";
restoreItems.value = [];
try {
await refreshInstalledSyncCandidates();
const result = await fetchSyncedAppList();
if (!isCurrentRestoreRequest(generation, userId)) return;
restoreItems.value = result?.items || [];
} catch (error: unknown) {
if (!isCurrentRestoreRequest(generation, userId)) return;
restoreError.value = (error as Error)?.message || "读取云端应用列表失败";
} finally {
if (isCurrentRestoreRequest(generation, userId)) {
restoreLoading.value = false;
}
}
};
const installCloudItems = (items: SyncedAppListItem[]): void => {
for (const item of items) {
const app = apps.value.find(
(candidate) =>
candidate.pkgname === item.pkgname &&
candidate.origin === item.origin &&
candidate.category === item.category,
);
if (!app) continue;
void onDetailInstall(app);
}
showRestoreModal.value = false;
};
const maybePromptInstalledSync = async (): Promise<void> => {
if (
import.meta.env.MODE === "test" ||
!isLoggedIn.value ||
installedSyncPromptShown.value ||
installedSyncEnabled.value !== null
) {
if (isLoggedIn.value && installedSyncEnabled.value === true) {
await syncInstalledAppsToAccount();
}
return;
}
installedSyncPromptShown.value = true;
const enabled = window.confirm(
"是否启用已安装应用列表自动同步到星火账号?仅同步商店识别的非依赖应用。",
);
setInstalledSyncEnabled(enabled);
if (enabled) await syncInstalledAppsToAccount();
};
const openUserManagement = async () => {
@@ -1954,6 +2081,7 @@ onMounted(async () => {
]).then(() => {
// 所有数据加载完成后的回调(可选)
logger.info("所有应用数据加载完成");
void maybePromptInstalledSync();
});
// 设置键盘导航
@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import AppListRestoreModal from "@/components/AppListRestoreModal.vue";
import type { SyncedAppListItem } from "@/global/typedefinition";
const createItem = (
overrides: Partial<SyncedAppListItem> = {},
): SyncedAppListItem => ({
pkgname: "spark-notes",
origin: "spark",
category: "office",
version: "1.0.0",
packageArch: "amd64",
appName: "Spark Notes",
iconUrl: "",
...overrides,
});
describe("AppListRestoreModal", () => {
it("emits selected installable cloud items", async () => {
const rendered = render(AppListRestoreModal, {
props: {
show: true,
loading: false,
error: "",
items: [
createItem(),
createItem({ pkgname: "amber-ce", appName: "Amber CE" }),
],
installedKeys: new Set<string>(),
},
});
await fireEvent.click(screen.getByLabelText("Spark Notes"));
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
expect(rendered.emitted("install-selected")?.[0]?.[0]).toEqual([
expect.objectContaining({ pkgname: "spark-notes" }),
]);
});
it("disables already installed cloud items", () => {
render(AppListRestoreModal, {
props: {
show: true,
loading: false,
error: "",
items: [createItem()],
installedKeys: new Set(["spark:spark-notes"]),
},
});
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
expect(screen.getByText("已安装")).toBeTruthy();
});
});
@@ -37,6 +37,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
@@ -57,6 +59,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
@@ -75,6 +79,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
@@ -97,6 +103,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
@@ -119,6 +127,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
@@ -136,9 +146,75 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
expect(screen.queryByRole("button", { name: "查看详情" })).toBeNull();
});
it("requests login for cloud actions when logged out", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
},
});
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
expect(rendered.emitted("request-login")).toHaveLength(2);
});
it("emits cloud sync and restore events when logged in", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: true,
syncing: false,
},
});
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
expect(rendered.emitted("sync-to-account")).toHaveLength(1);
expect(rendered.emitted("restore-from-account")).toHaveLength(1);
});
it("disables sync button while syncing", () => {
render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: true,
syncing: true,
},
});
expect(screen.getByRole("button", { name: "同步中" })).toBeDisabled();
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { buildSyncItems, cloudItemKey } from "@/modules/appListSync";
import type { App } from "@/global/typedefinition";
const createApp = (overrides: Partial<App> = {}): App => ({
name: "Spark Notes",
pkgname: "spark-notes",
version: "1.0.0",
filename: "spark-notes_1.0.0_amd64.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "1 MB",
more: "",
tags: "",
img_urls: [],
icons: "https://example.test/icon.png",
category: "office",
origin: "spark",
currentStatus: "installed",
...overrides,
});
describe("appListSync", () => {
it("builds cloud sync items for installed store-recognized user apps", () => {
expect(buildSyncItems([createApp()])).toEqual([
{
pkgname: "spark-notes",
origin: "spark",
category: "office",
version: "1.0.0",
packageArch: "amd64",
appName: "Spark Notes",
iconUrl: "https://example.test/icon.png",
},
]);
});
it("filters out non-installed unknown dependency and unusable package entries", () => {
const items = buildSyncItems([
createApp({ pkgname: "not-installed", currentStatus: "not-installed" }),
createApp({ pkgname: "unknown-app", category: "unknown" }),
createApp({ pkgname: "dependency", isDependency: true }),
createApp({ pkgname: "" }),
createApp({ pkgname: "blank-origin", origin: "spark" }),
createApp({ pkgname: "kept", origin: "apm", arch: "arm64" }),
]);
expect(items).toEqual([
expect.objectContaining({ pkgname: "blank-origin" }),
expect.objectContaining({ pkgname: "kept", packageArch: "arm64" }),
]);
});
it("uses pkgname as appName and blank icon when optional display fields are missing", () => {
const app = createApp({ icons: "", pkgname: "fallback-name" });
app.name = "";
const [syncItem] = buildSyncItems([app]);
expect(syncItem).toMatchObject({
appName: "fallback-name",
iconUrl: "",
});
});
it("builds stable installed keys from origin and package", () => {
expect(cloudItemKey({ origin: "apm", pkgname: "amber-ce" })).toBe(
"apm:amber-ce",
);
});
});
+172
View File
@@ -0,0 +1,172 @@
<template>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
@click.self="emit('close')"
>
<div
class="flex w-full max-w-3xl max-h-[85vh] flex-col rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex items-start justify-between border-b border-slate-200/70 p-6 dark:border-slate-800/70"
>
<div>
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
从账号恢复
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
选择云端已同步的应用加入安装队列
</p>
</div>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:hover:bg-slate-800 dark:hover:text-white"
aria-label="关闭"
@click="emit('close')"
>
<i class="fas fa-xmark"></i>
</button>
</div>
<div class="flex-1 overflow-y-auto p-6">
<div
v-if="loading"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
正在读取云端应用列表
</div>
<div
v-else-if="error"
class="rounded-2xl border border-rose-200/70 bg-rose-50/60 px-4 py-6 text-center text-sm text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10"
>
{{ error }}
</div>
<div
v-else-if="items.length === 0"
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
>
云端暂无已同步应用
</div>
<div v-else class="space-y-3">
<label
v-for="item in items"
:key="cloudItemKey(item)"
class="flex items-center gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70"
:class="isInstalled(item) ? 'opacity-60' : 'cursor-pointer'"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-brand focus:ring-brand"
:aria-label="item.appName || item.pkgname"
:checked="selectedKeys.has(cloudItemKey(item))"
:disabled="isInstalled(item)"
@change="toggleSelection(item)"
/>
<img
v-if="item.iconUrl"
:src="item.iconUrl"
class="h-10 w-10 rounded-xl object-contain"
alt=""
/>
<div
v-else
class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-400 dark:bg-slate-800"
>
<i class="fas fa-cube"></i>
</div>
<div class="min-w-0 flex-1">
<p class="font-semibold text-slate-900 dark:text-white">
{{ item.appName || item.pkgname }}
</p>
<p class="truncate text-xs text-slate-500 dark:text-slate-400">
{{ item.origin }} · {{ item.category }} · {{ item.pkgname }} ·
{{ item.version }} · {{ item.packageArch }}
</p>
</div>
<span
v-if="isInstalled(item)"
class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300"
>
已安装
</span>
</label>
</div>
</div>
<div
class="flex items-center justify-end gap-3 border-t border-slate-200/70 p-6 dark:border-slate-800/70"
>
<button
type="button"
class="rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="emit('close')"
>
取消
</button>
<button
type="button"
class="rounded-2xl bg-brand px-5 py-2 text-sm font-semibold text-white transition hover:bg-brand/90 disabled:opacity-40"
:disabled="selectedItems.length === 0"
@click="emit('install-selected', selectedItems)"
>
加入安装队列
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { SyncedAppListItem } from "@/global/typedefinition";
import { cloudItemKey } from "@/modules/appListSync";
const props = defineProps<{
show: boolean;
loading: boolean;
error: string;
items: SyncedAppListItem[];
installedKeys: Set<string>;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "install-selected", items: SyncedAppListItem[]): void;
}>();
const selectedKeys = ref<Set<string>>(new Set());
const isInstalled = (item: SyncedAppListItem): boolean =>
props.installedKeys.has(cloudItemKey(item));
const selectedItems = computed(() =>
props.items.filter((item) => selectedKeys.value.has(cloudItemKey(item))),
);
const toggleSelection = (item: SyncedAppListItem): void => {
if (isInstalled(item)) return;
const key = cloudItemKey(item);
const next = new Set(selectedKeys.value);
if (next.has(key)) next.delete(key);
else next.add(key);
selectedKeys.value = next;
};
watch(
() => [props.show, props.items] as const,
() => {
selectedKeys.value = new Set();
},
{ deep: true },
);
</script>
+31 -1
View File
@@ -28,6 +28,23 @@
</p>
</div>
<div class="flex items-center gap-3">
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10 disabled:opacity-40"
:disabled="syncing"
@click="handleSyncClick"
>
<i class="fas fa-cloud-arrow-up"></i>
{{ syncing ? "同步中" : "同步到账号" }}
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="handleRestoreClick"
>
<i class="fas fa-cloud-arrow-down"></i>
从账号恢复
</button>
<div
v-if="showOriginSwitcher"
class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70"
@@ -220,17 +237,30 @@ const props = defineProps<{
storeFilter: "spark" | "apm" | "both";
sparkAvailable: boolean;
apmAvailable: boolean;
loggedIn: boolean;
syncing: boolean;
}>();
defineEmits<{
const emit = defineEmits<{
(e: "close"): void;
(e: "refresh"): void;
(e: "uninstall", app: App): void;
(e: "switch-origin", origin: "apm" | "spark"): void;
(e: "open-app", app: App): void;
(e: "open-detail", app: App): void;
(e: "sync-to-account"): void;
(e: "restore-from-account"): void;
(e: "request-login"): void;
}>();
const handleSyncClick = () => {
emit(props.loggedIn ? "sync-to-account" : "request-login");
};
const handleRestoreClick = () => {
emit(props.loggedIn ? "restore-from-account" : "request-login");
};
const onOverlayWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
+30
View File
@@ -0,0 +1,30 @@
import type { App, SyncedAppListItem } from "@/global/typedefinition";
import { parsePackageArch } from "@/modules/appIdentity";
const hasUsablePackageIdentity = (app: App): boolean => {
return app.pkgname.trim().length > 0 && Boolean(app.origin);
};
export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => {
return apps
.filter(
(app) =>
app.currentStatus === "installed" &&
app.category !== "unknown" &&
!app.isDependency &&
hasUsablePackageIdentity(app),
)
.map((app) => ({
pkgname: app.pkgname,
origin: app.origin,
category: app.category,
version: app.version,
packageArch: app.arch || parsePackageArch(app.filename),
appName: app.name || app.pkgname,
iconUrl: app.icons || "",
}));
};
export const cloudItemKey = (
item: Pick<SyncedAppListItem, "origin" | "pkgname">,
): string => `${item.origin}:${item.pkgname}`;