# Spark Client Account Reviews Sync 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:** Integrate Spark Store's Electron/Vue client with the new backend for Flarum login, profile display, app reviews/ratings, immutable review tags, and user app-list sync/restore. **Architecture:** Keep backend API access in focused TypeScript modules, keep global auth state in one Vue module, and add small child components instead of expanding the already-large `AppDetailModal.vue` and `InstalledAppsModal.vue`. Local system facts such as distro are exposed through Electron IPC and combined with app metadata to create immutable review tags. **Tech Stack:** Electron 40, Vue 3 Composition API, TypeScript strict mode, Axios, Vitest, Testing Library Vue, existing IPC facade and install queue. --- ## File Structure Existing files to modify: - Modify: `.gitignore` - ignore `.superpowers/` visual companion artifacts. - Modify: `electron/main/index.ts` - add `get-system-info` IPC handler that safely reads `/etc/os-release`. - Modify: `src/vite-env.d.ts` - add auth/backend/system-info types. - Modify: `src/global/typedefinition.ts` - add user, review, rating, app-list sync types. - Modify: `src/global/storeConfig.ts` - add `SPARK_BACKEND_BASE_URL` config. - Modify: `src/components/AppHeader.vue` - show login/profile action. - Modify: `src/components/AppDetailModal.vue` - mount `ReviewsPanel` with the active display app. - Modify: `src/components/InstalledAppsModal.vue` - add sync/restore action buttons and restore modal entry events. - Modify: `src/App.vue` - coordinate auth modal, review props, app-list sync/restore flow, and system info loading. - Modify: `src/__tests__/setup.ts` - extend window mocks. New files to create: - Create: `src/global/authState.ts` - auth state, persistence, login/logout helpers. - Create: `src/modules/backendApi.ts` - Axios client and backend request helpers. - Create: `src/modules/reviewTags.ts` - immutable review tag construction and app key creation. - Create: `src/modules/appListSync.ts` - store-recognized installed-app filtering and restore-plan helpers. - Create: `src/components/LoginModal.vue` - Flarum login UI. - Create: `src/components/ReviewsPanel.vue` - rating summary, filters, review list, composer. - Create: `src/components/AppListRestoreModal.vue` - cloud app-list restore selector. - Create: `src/__tests__/unit/reviewTags.test.ts` - immutable tag tests. - Create: `src/__tests__/unit/appListSync.test.ts` - sync filtering tests. - Create: `src/__tests__/unit/LoginModal.test.ts` - login UI tests. - Create: `src/__tests__/unit/ReviewsPanel.test.ts` - review panel state tests. - Create: `src/__tests__/unit/AppListRestoreModal.test.ts` - restore modal tests. ## Task 1: Add Backend Config And Shared Types **Files:** - Modify: `.gitignore` - Modify: `src/global/storeConfig.ts` - Modify: `src/global/typedefinition.ts` - Modify: `src/vite-env.d.ts` - Test: `src/__tests__/setup.ts` - [ ] **Step 1: Write type/import smoke test** Create `src/__tests__/unit/accountTypes.test.ts` with: ```typescript import { describe, expect, it } from "vitest"; import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; import type { ReviewTags, SparkUser } from "@/global/typedefinition"; describe("account backend types", () => { it("exports backend url and account types", () => { const user: SparkUser = { id: 1, flarumUserId: "123", username: "momen", displayName: "Momen", avatarUrl: "https://bbs.spark-app.store/avatar.png", }; const tags: ReviewTags = { origin: "apm", category: "office", pkgname: "wps", version: "1.0.0", packageArch: "amd64", clientArch: "amd64", distro: "deepin 25", }; expect(typeof SPARK_BACKEND_BASE_URL).toBe("string"); expect(user.displayName).toBe("Momen"); expect(tags.packageArch).toBe("amd64"); }); }); ``` - [ ] **Step 2: Run test to verify failure** Run: `npm run test -- src/__tests__/unit/accountTypes.test.ts` Expected: FAIL because `SPARK_BACKEND_BASE_URL`, `SparkUser`, and `ReviewTags` do not exist. - [ ] **Step 3: Ignore visual companion artifacts** Modify `.gitignore` by adding: ```gitignore .superpowers/ ``` - [ ] **Step 4: Add backend config** Modify `src/global/storeConfig.ts` by adding after `APM_STORE_STATS_BASE_URL`: ```typescript export const SPARK_BACKEND_BASE_URL: string = import.meta.env.VITE_SPARK_BACKEND_BASE_URL || ""; ``` - [ ] **Step 5: Add shared account types** Append to `src/global/typedefinition.ts`: ```typescript export interface SparkUser { id: number; flarumUserId: string; username: string; displayName: string; avatarUrl: string; } export interface AuthSession { accessToken: string; tokenType: "bearer"; user: SparkUser; } export interface ReviewTags { origin: "spark" | "apm"; category: string; pkgname: string; version: string; packageArch: string; clientArch: string; distro: string; } export interface RatingSummary { averageRating: number; reviewCount: number; starCounts: Record; } export interface AppReview { id: number; rating: number; content: string; version: string; packageArch: string; clientArch: string; distro: string; origin: "spark" | "apm"; category: string; createdAt: string; updatedAt: string; userDisplayName: string; userAvatarUrl: string; } export interface SyncedAppListItem { id?: number; pkgname: string; origin: "spark" | "apm"; category: string; version: string; packageArch: string; appName: string; iconUrl: string; } export interface SyncedAppList { snapshotName: string; clientArch: string; distro: string; updatedAt: string; items: SyncedAppListItem[]; } export interface SystemInfo { distro: string; } ``` - [ ] **Step 6: Extend environment declarations** Modify `src/vite-env.d.ts` by adding to `ImportMetaEnv`: ```typescript interface ImportMetaEnv { readonly VITE_SPARK_BACKEND_BASE_URL?: string; } ``` - [ ] **Step 7: Extend test setup mocks** Modify `src/__tests__/setup.ts` so `window.apm_store.arch` is `amd64`, not `amd64-store`, and `ipcRenderer.invoke` can be overridden per test: ```typescript Object.defineProperty(window, "apm_store", { value: { arch: "amd64", }, writable: true, }); ``` - [ ] **Step 8: Run test to verify pass** Run: `npm run test -- src/__tests__/unit/accountTypes.test.ts` Expected: PASS. - [ ] **Step 9: Commit shared config/types** Run: ```bash git add .gitignore src/global/storeConfig.ts src/global/typedefinition.ts src/vite-env.d.ts src/__tests__/setup.ts src/__tests__/unit/accountTypes.test.ts git commit -m "feat(account): add backend config and types" ``` Expected: commit succeeds if the user requested commits for implementation execution. ## Task 2: Add Backend API Client And Auth State **Files:** - Create: `src/modules/backendApi.ts` - Create: `src/global/authState.ts` - Test: `src/__tests__/unit/authState.test.ts` - [ ] **Step 1: Write failing auth state test** Create `src/__tests__/unit/authState.test.ts` with: ```typescript import { beforeEach, describe, expect, it, vi } from "vitest"; describe("authState", () => { beforeEach(() => { vi.resetModules(); localStorage.clear(); }); it("persists and clears a backend session", async () => { const { authSession, setAuthSession, logout } = await import("@/global/authState"); setAuthSession({ accessToken: "jwt", tokenType: "bearer", user: { id: 1, flarumUserId: "123", username: "momen", displayName: "Momen", avatarUrl: "https://bbs.spark-app.store/avatar.png", }, }); expect(authSession.value?.accessToken).toBe("jwt"); expect(JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken).toBe("jwt"); logout(); expect(authSession.value).toBeNull(); expect(localStorage.getItem("spark-store-auth")).toBeNull(); }); }); ``` - [ ] **Step 2: Run test to verify failure** Run: `npm run test -- src/__tests__/unit/authState.test.ts` Expected: FAIL because `authState` module does not exist. - [ ] **Step 3: Add backend API client** Create `src/modules/backendApi.ts` with: ```typescript import axios from "axios"; import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; import type { AppReview, AuthSession, RatingSummary, ReviewTags, SyncedAppList, SyncedAppListItem, } from "@/global/typedefinition"; const backend = axios.create({ baseURL: SPARK_BACKEND_BASE_URL, timeout: 10000, }); export const setBackendToken = (token: string | null) => { if (token) { backend.defaults.headers.common.Authorization = `Bearer ${token}`; } else { delete backend.defaults.headers.common.Authorization; } }; const toCamelReview = (raw: Record): AppReview => ({ id: Number(raw.id), rating: Number(raw.rating), content: String(raw.content || ""), version: String(raw.version || "unknown"), packageArch: String(raw.package_arch || "unknown"), clientArch: String(raw.client_arch || "unknown"), distro: String(raw.distro || "unknown"), origin: raw.origin === "spark" ? "spark" : "apm", category: String(raw.category || ""), createdAt: String(raw.created_at || ""), updatedAt: String(raw.updated_at || ""), userDisplayName: String(raw.user_display_name || ""), userAvatarUrl: String(raw.user_avatar_url || ""), }); export const exchangeFlarumToken = async (payload: { flarumUserId: string; flarumToken: string; }): Promise => { const response = await backend.post("/auth/flarum", { flarum_user_id: payload.flarumUserId, flarum_token: payload.flarumToken, }); const data = response.data; return { accessToken: data.access_token, tokenType: data.token_type, user: { id: data.user.id, flarumUserId: data.user.flarum_user_id, username: data.user.username, displayName: data.user.display_name, avatarUrl: data.user.avatar_url, }, }; }; export const fetchRatingSummary = async (appKey: string): Promise => { const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/rating-summary`); return { averageRating: Number(response.data.average_rating || 0), reviewCount: Number(response.data.review_count || 0), starCounts: response.data.star_counts || {}, }; }; export const fetchReviews = async (appKey: string, params: Record): Promise => { const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/reviews`, { params }); return (response.data || []).map((item: Record) => toCamelReview(item)); }; export const submitReview = async (appKey: string, payload: { rating: number; content: string; tags: ReviewTags }): Promise => { const response = await backend.post(`/apps/${encodeURIComponent(appKey)}/reviews`, { rating: payload.rating, content: payload.content, tags: { origin: payload.tags.origin, category: payload.tags.category, pkgname: payload.tags.pkgname, version: payload.tags.version, package_arch: payload.tags.packageArch, client_arch: payload.tags.clientArch, distro: payload.tags.distro, }, }); return toCamelReview(response.data); }; export const fetchSyncedAppList = async (): Promise => { const response = await backend.get("/me/app-list"); if (!response.data) return null; return { snapshotName: response.data.snapshot_name, clientArch: response.data.client_arch, distro: response.data.distro, updatedAt: response.data.updated_at, items: (response.data.items || []).map((item: Record) => ({ id: Number(item.id), pkgname: String(item.pkgname || ""), origin: item.origin === "spark" ? "spark" : "apm", category: String(item.category || ""), version: String(item.version || ""), packageArch: String(item.package_arch || "unknown"), appName: String(item.app_name || ""), iconUrl: String(item.icon_url || ""), })), }; }; export const uploadSyncedAppList = async (payload: { clientArch: string; distro: string; items: SyncedAppListItem[]; }): Promise => { const response = await backend.put("/me/app-list", { client_arch: payload.clientArch, distro: payload.distro, items: payload.items.map((item) => ({ pkgname: item.pkgname, origin: item.origin, category: item.category, version: item.version, package_arch: item.packageArch, app_name: item.appName, icon_url: item.iconUrl, })), }); return response.data; }; ``` - [ ] **Step 4: Add auth state** Create `src/global/authState.ts` with: ```typescript import { computed, ref } from "vue"; import { setBackendToken } from "@/modules/backendApi"; import type { AuthSession } from "@/global/typedefinition"; const STORAGE_KEY = "spark-store-auth"; const readStoredSession = (): AuthSession | null => { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as AuthSession; if (!parsed.accessToken || !parsed.user) return null; return parsed; } catch { return null; } }; export const authSession = ref(readStoredSession()); export const currentUser = computed(() => authSession.value?.user ?? null); export const isLoggedIn = computed(() => Boolean(authSession.value?.accessToken)); setBackendToken(authSession.value?.accessToken ?? null); export const setAuthSession = (session: AuthSession) => { authSession.value = session; localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); setBackendToken(session.accessToken); }; export const logout = () => { authSession.value = null; localStorage.removeItem(STORAGE_KEY); setBackendToken(null); }; ``` - [ ] **Step 5: Run auth state test and verify pass** Run: `npm run test -- src/__tests__/unit/authState.test.ts` Expected: PASS. - [ ] **Step 6: Commit API/auth state** Run: ```bash git add src/modules/backendApi.ts src/global/authState.ts src/__tests__/unit/authState.test.ts git commit -m "feat(account): add backend api and auth state" ``` Expected: commit succeeds if commits are requested for implementation execution. ## Task 3: Add Flarum Login Modal And Header Account UI **Files:** - Create: `src/components/LoginModal.vue` - Modify: `src/components/AppHeader.vue` - Modify: `src/App.vue` - Test: `src/__tests__/unit/LoginModal.test.ts` - [ ] **Step 1: Write failing LoginModal test** Create `src/__tests__/unit/LoginModal.test.ts` with: ```typescript import { fireEvent, render, screen } from "@testing-library/vue"; import { describe, expect, it, vi } from "vitest"; import LoginModal from "@/components/LoginModal.vue"; describe("LoginModal", () => { it("emits login credentials", async () => { const rendered = render(LoginModal, { props: { show: true, loading: false, error: "" }, }); await fireEvent.update(screen.getByLabelText("论坛账号"), "momen"); await fireEvent.update(screen.getByLabelText("论坛密码"), "secret"); await fireEvent.click(screen.getByRole("button", { name: "登录" })); expect(rendered.emitted("login")?.[0]?.[0]).toEqual({ identification: "momen", password: "secret" }); }); }); ``` - [ ] **Step 2: Run test to verify failure** Run: `npm run test -- src/__tests__/unit/LoginModal.test.ts` Expected: FAIL because `LoginModal.vue` does not exist. - [ ] **Step 3: Create LoginModal component** Create `src/components/LoginModal.vue` with: ```vue ``` - [ ] **Step 4: Run LoginModal test and verify pass** Run: `npm run test -- src/__tests__/unit/LoginModal.test.ts` Expected: PASS. - [ ] **Step 5: Add account UI props to header** Modify `src/components/AppHeader.vue` props and emits: ```typescript import type { SparkUser } from "@/global/typedefinition"; const props = defineProps<{ searchQuery: string; activeTab: string; appsCount: number; currentUser: SparkUser | null; }>(); const emit = defineEmits<{ (e: "update-search", query: string): void; (e: "search-focus"): void; (e: "open-install-settings"): void; (e: "open-about"): void; (e: "toggle-sidebar"): void; (e: "login"): void; (e: "logout"): void; }>(); ``` Add a button after the About button: ```vue ``` - [ ] **Step 6: Wire login modal in App.vue** Modify `src/App.vue` to import `LoginModal`, `currentUser`, `setAuthSession`, `logout`, and `exchangeFlarumToken`. Add state: ```typescript const showLoginModal = ref(false); const loginLoading = ref(false); const loginError = ref(""); ``` Pass `:current-user="currentUser"` to `AppHeader`, add `@login="showLoginModal = true"` and `@logout="logout"`, and mount: ```vue ``` Add handler: ```typescript const handleFlarumLogin = async (payload: { identification: string; password: string }) => { loginLoading.value = true; loginError.value = ""; try { const response = await axios.post("https://bbs.spark-app.store/api/token", { identification: payload.identification, password: payload.password, }); const session = await exchangeFlarumToken({ flarumUserId: String(response.data.userId), flarumToken: String(response.data.token), }); setAuthSession(session); showLoginModal.value = false; } catch (error) { loginError.value = error instanceof Error ? error.message : "登录失败"; } finally { loginLoading.value = false; } }; ``` - [ ] **Step 7: Run targeted tests** Run: `npm run test -- src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/authState.test.ts` Expected: PASS. - [ ] **Step 8: Commit login UI** Run: ```bash git add src/components/LoginModal.vue src/components/AppHeader.vue src/App.vue src/__tests__/unit/LoginModal.test.ts git commit -m "feat(account): add Flarum login UI" ``` Expected: commit succeeds if commits are requested for implementation execution. ## Task 4: Add System Info IPC And Immutable Review Tags **Files:** - Modify: `electron/main/index.ts` - Create: `src/modules/reviewTags.ts` - Test: `src/__tests__/unit/reviewTags.test.ts` - [ ] **Step 1: Write failing review tag tests** Create `src/__tests__/unit/reviewTags.test.ts` with: ```typescript import { describe, expect, it } from "vitest"; import { buildAppKey, buildReviewTags, parsePackageArch } from "@/modules/reviewTags"; import type { App } from "@/global/typedefinition"; const app: App = { name: "WPS", pkgname: "wps", version: "1.0.0", filename: "wps_1.0.0_amd64.deb", torrent_address: "", author: "", contributor: "", website: "", update: "", size: "", more: "", tags: "", img_urls: [], icons: "", category: "office", origin: "apm", currentStatus: "installed", }; describe("reviewTags", () => { it("builds stable app keys", () => { expect(buildAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps"); }); it("parses package architecture from deb filename", () => { expect(parsePackageArch("wps_1.0.0_amd64.deb")).toBe("amd64"); }); it("builds immutable review tags", () => { expect(buildReviewTags(app, { clientArch: "amd64", distro: "deepin 25" })).toEqual({ origin: "apm", category: "office", pkgname: "wps", version: "1.0.0", packageArch: "amd64", clientArch: "amd64", distro: "deepin 25", }); }); }); ``` - [ ] **Step 2: Run test to verify failure** Run: `npm run test -- src/__tests__/unit/reviewTags.test.ts` Expected: FAIL because `reviewTags` module does not exist. - [ ] **Step 3: Add review tag module** Create `src/modules/reviewTags.ts` with: ```typescript import type { App, ReviewTags } from "@/global/typedefinition"; export const parsePackageArch = (filename: string | undefined): string => { if (!filename) return "unknown"; const match = filename.match(/_([^_]+)\.(?:deb|rpm|appimage|tar\.gz)$/i); return match?.[1] || "unknown"; }; export const buildStoreArch = (origin: "spark" | "apm", clientArch: string): string => { return origin === "spark" ? `${clientArch}-store` : `${clientArch}-apm`; }; export const buildAppKey = (app: App, clientArch: string): string => { return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category}:${app.pkgname}`; }; export const buildReviewTags = ( app: App, system: { clientArch: string; distro: string }, ): ReviewTags => ({ origin: app.origin, category: app.category || "unknown", pkgname: app.pkgname, version: app.version || "unknown", packageArch: app.arch || parsePackageArch(app.filename), clientArch: system.clientArch || "unknown", distro: system.distro || "unknown", }); ``` - [ ] **Step 4: Add system info IPC** Modify `electron/main/index.ts` by adding near `get-app-version`: ```typescript const getSystemInfo = (): { distro: string } => { try { const raw = fs.readFileSync("/etc/os-release", "utf8"); const values = Object.fromEntries( raw .split("\n") .filter((line) => line.includes("=")) .map((line) => { const [key, ...rest] = line.split("="); return [key, rest.join("=").replace(/^"|"$/g, "")]; }), ); const name = values.PRETTY_NAME || values.NAME || values.ID || "unknown"; return { distro: name }; } catch { return { distro: "unknown" }; } }; ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo()); ``` - [ ] **Step 5: Run review tag tests and verify pass** Run: `npm run test -- src/__tests__/unit/reviewTags.test.ts` Expected: PASS. - [ ] **Step 6: Commit tags/system info** Run: ```bash git add electron/main/index.ts src/modules/reviewTags.ts src/__tests__/unit/reviewTags.test.ts git commit -m "feat(reviews): add immutable review tags" ``` Expected: commit succeeds if commits are requested for implementation execution. ## Task 5: Add ReviewsPanel To App Detail Modal **Files:** - Create: `src/components/ReviewsPanel.vue` - Modify: `src/components/AppDetailModal.vue` - Modify: `src/App.vue` - Test: `src/__tests__/unit/ReviewsPanel.test.ts` - [ ] **Step 1: Write failing ReviewsPanel test** Create `src/__tests__/unit/ReviewsPanel.test.ts` with: ```typescript import { render, screen } from "@testing-library/vue"; import { describe, expect, it } from "vitest"; import ReviewsPanel from "@/components/ReviewsPanel.vue"; import type { ReviewTags } from "@/global/typedefinition"; const tags: ReviewTags = { origin: "apm", category: "office", pkgname: "wps", version: "1.0.0", packageArch: "amd64", clientArch: "amd64", distro: "deepin 25", }; describe("ReviewsPanel", () => { it("shows login prompt for anonymous users and read-only tags", () => { render(ReviewsPanel, { props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false, }, }); expect(screen.getByText("登录后发表评论")).toBeTruthy(); expect(screen.getByText("1.0.0")).toBeTruthy(); expect(screen.getByText("amd64")).toBeTruthy(); }); }); ``` - [ ] **Step 2: Run test to verify failure** Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts` Expected: FAIL because `ReviewsPanel.vue` does not exist. - [ ] **Step 3: Create ReviewsPanel component** Create `src/components/ReviewsPanel.vue` with: ```vue ``` - [ ] **Step 4: Run ReviewsPanel test and verify pass** Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts` Expected: PASS. - [ ] **Step 5: Mount ReviewsPanel in AppDetailModal** Modify `src/components/AppDetailModal.vue`: 1. Import `ReviewsPanel`. 2. Add props: ```typescript reviewAppKey: string; reviewTags: ReviewTags | null; loggedIn: boolean; ``` 3. Add emit: ```typescript (e: "request-login"): void; ``` 4. Add below the screenshot block: ```vue ``` - [ ] **Step 6: Build review props in App.vue** Modify `src/App.vue`: 1. Import `buildAppKey` and `buildReviewTags`. 2. Add refs: ```typescript const systemInfo = ref({ distro: "unknown" }); ``` 3. On mount, call: ```typescript systemInfo.value = await window.ipcRenderer.invoke("get-system-info"); ``` 4. Add computed values: ```typescript const currentDisplayAppForReview = computed(() => { const app = currentApp.value; if (!app) return null; if (!app.isMerged) return app; return app.viewingOrigin === "spark" ? app.sparkApp || app : app.apmApp || app; }); const currentReviewAppKey = computed(() => { const app = currentDisplayAppForReview.value; return app ? buildAppKey(app, window.apm_store.arch || "amd64") : ""; }); const currentReviewTags = computed(() => { const app = currentDisplayAppForReview.value; return app ? buildReviewTags(app, { clientArch: window.apm_store.arch || "amd64", distro: systemInfo.value.distro, }) : null; }); ``` 5. Pass these props to `AppDetailModal` and wire `@request-login="showLoginModal = true"`. - [ ] **Step 7: Run targeted tests** Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/reviewTags.test.ts` Expected: PASS. - [ ] **Step 8: Commit review panel** Run: ```bash git add src/components/ReviewsPanel.vue src/components/AppDetailModal.vue src/App.vue src/__tests__/unit/ReviewsPanel.test.ts git commit -m "feat(reviews): show app reviews in details" ``` Expected: commit succeeds if commits are requested for implementation execution. ## Task 6: Add App-List Sync Filtering **Files:** - Create: `src/modules/appListSync.ts` - Test: `src/__tests__/unit/appListSync.test.ts` - [ ] **Step 1: Write failing sync filtering tests** Create `src/__tests__/unit/appListSync.test.ts` with: ```typescript import { describe, expect, it } from "vitest"; import { buildSyncItems } from "@/modules/appListSync"; import type { App } from "@/global/typedefinition"; const baseApp: 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: "", more: "", tags: "", img_urls: [], icons: "", category: "office", origin: "spark", currentStatus: "installed", }; describe("appListSync", () => { it("syncs only store-recognized non-dependency apps", () => { const items = buildSyncItems([ baseApp, { ...baseApp, pkgname: "unknown", category: "unknown" }, { ...baseApp, pkgname: "dep", isDependency: true }, ]); expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ pkgname: "spark-notes", origin: "spark", category: "office" }); }); }); ``` - [ ] **Step 2: Run test to verify failure** Run: `npm run test -- src/__tests__/unit/appListSync.test.ts` Expected: FAIL because `appListSync` module does not exist. - [ ] **Step 3: Add sync filtering module** Create `src/modules/appListSync.ts` with: ```typescript import type { App, SyncedAppListItem } from "@/global/typedefinition"; import { parsePackageArch } from "@/modules/reviewTags"; export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => { return apps .filter((app) => app.currentStatus === "installed") .filter((app) => app.category !== "unknown") .filter((app) => !app.isDependency) .filter((app) => Boolean(app.pkgname && app.origin)) .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 isCloudItemInstalled = ( item: SyncedAppListItem, installedApps: App[], ): boolean => { return installedApps.some((app) => app.pkgname === item.pkgname && app.origin === item.origin && app.currentStatus === "installed"); }; ``` - [ ] **Step 4: Run sync filtering tests and verify pass** Run: `npm run test -- src/__tests__/unit/appListSync.test.ts` Expected: PASS. - [ ] **Step 5: Commit sync helpers** Run: ```bash git add src/modules/appListSync.ts src/__tests__/unit/appListSync.test.ts git commit -m "feat(sync): add app list filtering" ``` Expected: commit succeeds if commits are requested for implementation execution. ## Task 7: Add Sync And Restore UI **Files:** - Create: `src/components/AppListRestoreModal.vue` - Modify: `src/components/InstalledAppsModal.vue` - Modify: `src/App.vue` - Test: `src/__tests__/unit/AppListRestoreModal.test.ts` - Test: `src/__tests__/unit/InstalledAppsModal.test.ts` - [ ] **Step 1: Write failing restore modal test** Create `src/__tests__/unit/AppListRestoreModal.test.ts` with: ```typescript import { fireEvent, render, screen } from "@testing-library/vue"; import { describe, expect, it } from "vitest"; import AppListRestoreModal from "@/components/AppListRestoreModal.vue"; describe("AppListRestoreModal", () => { it("emits selected installable items", async () => { const rendered = render(AppListRestoreModal, { props: { show: true, loading: false, error: "", items: [ { pkgname: "spark-notes", origin: "spark", category: "office", version: "1.0.0", packageArch: "amd64", appName: "Spark Notes", iconUrl: "", }, ], installedKeys: [], }, }); await fireEvent.click(screen.getByLabelText("选择 Spark Notes")); await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" })); expect(rendered.emitted("install-selected")?.[0]?.[0]).toHaveLength(1); }); }); ``` - [ ] **Step 2: Run test to verify failure** Run: `npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts` Expected: FAIL because `AppListRestoreModal.vue` does not exist. - [ ] **Step 3: Create restore modal** Create `src/components/AppListRestoreModal.vue` with: 1. Props `show`, `loading`, `error`, `items: SyncedAppListItem[]`, `installedKeys: string[]`. 2. Emits `close` and `install-selected`. 3. Local selected map keyed by `${origin}:${pkgname}`. 4. Checkbox per item with accessible label `选择 ${item.appName || item.pkgname}`. 5. Disable checkbox when installed key is present. 6. Button text `加入安装队列`. - [ ] **Step 4: Run restore modal test and verify pass** Run: `npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts` Expected: PASS. - [ ] **Step 5: Add sync buttons to InstalledAppsModal** Modify `src/components/InstalledAppsModal.vue`: 1. Add props: ```typescript loggedIn: boolean; syncing: boolean; ``` 2. Add emits: ```typescript (e: "sync-to-account"): void; (e: "restore-from-account"): void; (e: "request-login"): void; ``` 3. Add header buttons before Refresh: ```vue ``` 4. Update existing `InstalledAppsModal.test.ts` props to pass `loggedIn: false` and `syncing: false`. - [ ] **Step 6: Wire sync/restore in App.vue** Modify `src/App.vue`: 1. Import `AppListRestoreModal`, `buildSyncItems`, `fetchSyncedAppList`, and `uploadSyncedAppList`. 2. Add state: ```typescript const syncLoading = ref(false); const restoreLoading = ref(false); const restoreError = ref(""); const showRestoreModal = ref(false); const restoreItems = ref([]); ``` 3. Pass `:logged-in="isLoggedIn"` and `:syncing="syncLoading"` to `InstalledAppsModal`. 4. Add handlers: ```typescript const syncInstalledAppsToAccount = async () => { syncLoading.value = true; try { const items = buildSyncItems(installedApps.value); await uploadSyncedAppList({ clientArch: window.apm_store.arch || "amd64", distro: systemInfo.value.distro, items, }); } finally { syncLoading.value = false; } }; const openRestoreFromAccount = async () => { restoreLoading.value = true; restoreError.value = ""; showRestoreModal.value = true; try { const list = await fetchSyncedAppList(); restoreItems.value = list?.items || []; } catch (error) { restoreError.value = error instanceof Error ? error.message : "读取云端列表失败"; } finally { restoreLoading.value = false; } }; ``` 5. Mount `AppListRestoreModal` and on `install-selected`, map selected cloud items to catalog `App` entries from `apps.value` by `pkgname`, `origin`, and `category`, then call existing `handleInstall(app)` for each. - [ ] **Step 7: Run targeted tests** Run: `npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts src/__tests__/unit/appListSync.test.ts` Expected: PASS. - [ ] **Step 8: Commit sync UI** Run: ```bash git add src/components/AppListRestoreModal.vue src/components/InstalledAppsModal.vue src/App.vue src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts git commit -m "feat(sync): add account app restore UI" ``` Expected: commit succeeds if commits are requested for implementation execution. ## Task 8: Final Client Verification **Files:** - Verify: no planned file edits in this task. - [ ] **Step 1: Run account-related unit tests** Run: ```bash npm run test -- src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/reviewTags.test.ts src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts ``` Expected: all listed tests PASS. - [ ] **Step 2: Run full unit test suite** Run: `npm run test` Expected: all unit tests PASS. - [ ] **Step 3: Run lint** Run: `npm run lint` Expected: exits 0 with no lint errors. - [ ] **Step 4: Run Vite build** Run: `npm run build:vite` Expected: exits 0 and produces renderer/main build artifacts. - [ ] **Step 5: Check working tree** Run: `git status --short` Expected: only intentional changes remain. `.superpowers/` must not appear because `.gitignore` ignores it. ## Self-Review Checklist Spec coverage: - Login and avatar/name display: Tasks 2 and 3. - Direct client-to-Flarum token login: Task 3. - Backend JWT storage and use: Task 2. - Detail-page review panel: Task 5. - Immutable automatic tags: Task 4. - Review filtering foundation: Task 5. - Store-recognized app-list sync: Tasks 6 and 7. - Restore through existing install queue: Task 7. Placeholder scan: - The plan has no deferred implementation sections and no placeholder tasks. Type consistency: - Backend snake_case payloads are mapped in `backendApi.ts`. - UI state uses camelCase `SparkUser`, `ReviewTags`, and `SyncedAppListItem`. - `window.apm_store.arch` is treated as bare architecture such as `amd64`.