diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a80d05b..f5eee54a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## [未发布 / Unreleased] (Erotica 分支,基于 1e60a4c2 之后) + +本批改动汇总(自提交 `1e60a4c2` 起): + +1. **已安装应用新增搜索框 + 窗口尺寸/位置持久化**:补齐已安装面板的应用搜索框;主窗口大小、位置、最大化状态写入本地 `window-state.json` 持久化,并限制最小尺寸 800×500。 +2. **已安装列表健壮性加固**:修复异步竞态、错误 UX、加载状态处理与刷新防抖(多轮 AI 审查改进),并清理对应代码缩进与对齐。 +3. **各页面滚动条贴边修复**:避免滚动栏被窗口圆角裁切。 +4. **投稿应用窗口圆角虚影修复**:消除投稿弹窗圆角处的方角残影。 +5. **已安装应用来源筛选与排序优化**:将头部 APM / Spark / 总数三段式统计徽章改为可点击,点击后按来源筛选(默认显示全部);列表默认 APM 应用置顶;新增"暂无已安装的 APM/Spark 应用"空状态提示,避免筛选无结果时出现空引号。 +6. **AI 代码审查安全加固**: + - `launch-app` IPC 移除 `any`,新增包名正则校验(`/^[a-zA-Z0-9._+-]+$/`),拦截非法输入防止命令注入。 + - `getIconUrl` 本地图标路径增加白名单目录校验与路径遍历(`..`)防护。 + - `fetchWithRetry` 仅对网络错误 / 5xx / 超时重试,4xx 快速失败。 + - `AppGrid` 普通网格 `v-for` 的 `:key` 由 `index` 改为 `app.pkgname`。 + - `loong64` 架构下尊重 `--no-spark` 启动参数,不再硬编码覆盖。 + - `openDownloadedApp` 的 IPC 调用补充 `.catch` 错误处理。 + +相关提交:`1d9d25d6`、`c75938aa`、`39a358bb`、`51473cac`、`cf0e72ff`、`b0d60737`、`d3705ed4`。 + ## [1.1.1](https://github.com/elysia-best/apm-app-store/compare/v1.1.0...v1.1.1) (2026-02-17) diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index 4dbd985d..5eee6ac7 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -997,8 +997,16 @@ ipcMain.handle( }> = []; if (origin === "spark") { - // 如果提供了包名列表,只检查这些包的安装状态(优化版) - if (pkgnameList && pkgnameList.length > 0) { + // 显式传入了包名列表(可能是空数组):只检查这些包的安装状态(优化版) + if (Array.isArray(pkgnameList)) { + if (pkgnameList.length === 0) { + // 商店目录尚未加载或该来源没有任何可枚举的包时, + // 直接返回空列表,避免退化为“全量扫描整个系统”后再被渲染端全部跳过, + // 否则会误报“已安装应用为空”。 + logger.info("Spark 包名列表为空,跳过已安装检查"); + return { success: true, apps: [] }; + } + logger.info( `使用优化模式检查 ${pkgnameList.length} 个 Spark 包的安装状态`, ); @@ -1040,7 +1048,7 @@ ipcMain.handle( return { success: true, apps: installedApps }; } - // 回退到全量扫描模式(未提供包名列表时) + // 回退到全量扫描模式(仅当调用方未传入 pkgnameList 时,例如旧版直接调用) logger.info("使用全量扫描模式获取所有 Spark 已安装包"); const { code, stdout } = await runCommandCapture("dpkg-query", [ "-W", @@ -1274,31 +1282,52 @@ ipcMain.handle("uninstall-installed", async (_event, payload: any) => { }; }); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -ipcMain.handle("launch-app", async (_event, payload: any) => { - const pkgname = typeof payload === "string" ? payload : payload.pkgname; - const origin = typeof payload === "string" ? "spark" : payload.origin; +interface LaunchAppPayload { + pkgname: string; + origin?: "spark" | "apm"; +} - if (!pkgname) { - logger.warn("No pkgname provided for launch-app"); - } +// 合法包名字符(Debian 包名规范 + Spark 应用包名常见字符), +// 用于拦截包含特殊字符的非法输入,避免命令注入。 +const PKGNAME_PATTERN = /^[a-zA-Z0-9._+-]+$/; - let execCommand = "/opt/spark-store/extras/app-launcher"; - let execParams = ["start", pkgname]; +ipcMain.handle( + "launch-app", + async ( + _event, + payload: LaunchAppPayload, + ): Promise<{ success: boolean; message?: string }> => { + const pkgname = typeof payload === "string" ? payload : payload.pkgname; + const origin = typeof payload === "string" ? "spark" : payload.origin; - if (origin === "apm") { - execCommand = "apm"; - execParams = ["launch", pkgname]; - } + if ( + !pkgname || + typeof pkgname !== "string" || + !PKGNAME_PATTERN.test(pkgname) + ) { + logger.warn(`Invalid pkgname provided for launch-app: ${pkgname}`); + return { success: false, message: "Invalid package name" }; + } - logger.info( - `Launching app: ${pkgname} with command: ${execCommand} ${execParams.join(" ")}`, - ); + let execCommand = "/opt/spark-store/extras/app-launcher"; + let execParams = ["start", pkgname]; - spawn(execCommand, execParams, { - shell: false, - env: process.env, - detached: true, - stdio: "ignore", - }).unref(); -}); + if (origin === "apm") { + execCommand = "apm"; + execParams = ["launch", pkgname]; + } + + logger.info( + `Launching app: ${pkgname} with command: ${execCommand} ${execParams.join(" ")}`, + ); + + spawn(execCommand, execParams, { + shell: false, + env: process.env, + detached: true, + stdio: "ignore", + }).unref(); + + return { success: true }; + }, +); diff --git a/electron/main/index.ts b/electron/main/index.ts index 97f7451b..7911d5d7 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -7,6 +7,7 @@ import { shell, Tray, nativeTheme, + screen, session, } from "electron"; import { fileURLToPath } from "node:url"; @@ -136,7 +137,9 @@ logger.info("User Agent: " + getUserAgent()); /** 根据启动参数 --no-apm / --no-spark 决定只展示的来源 */ function getStoreFilterFromArgv(): "spark" | "apm" | "both" { if (process.arch === "loong64") { - // Currently loong64 only have spark support + // Currently loong64 only have spark support, + // 但用户显式传入 --no-spark 时应允许回退到 apm + if (process.argv.includes("--no-spark")) return "apm"; return "spark"; } else { const argv = process.argv; @@ -153,6 +156,12 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" => getStoreFilterFromArgv(), ); +// 渲染端在窗口尺寸变化时(包括无边框窗口鼠标拉边角)经此保存当前窗口尺寸 +ipcMain.handle("save-window-bounds", (): boolean => { + if (win && !win.isDestroyed()) scheduleSaveBounds(win); + return true; +}); + ipcMain.handle("get-app-version", (): string => getAppVersion()); ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo()); @@ -277,11 +286,103 @@ const showAndFocusMainWindow = (): void => { win.focus(); }; +// 窗口尺寸持久化:保存/恢复上一次调整后的窗口大小,避免每次打开都使用默认尺寸 +const DEFAULT_WINDOW_SIZE = { width: 1366, height: 768 }; +const MIN_WINDOW_SIZE = { width: 800, height: 500 }; + +interface WindowState { + width?: number; + height?: number; + x?: number; + y?: number; + maximized?: boolean; +} + +function getWindowStatePath(): string { + // 延迟到调用时再取 userData,避免在 app ready 之前调用 app.getPath 出错 + return path.join(app.getPath("userData"), "window-state.json"); +} + +// 校验保存的窗口位置是否至少部分落在某个显示器可见区域内,避免窗口跑到屏幕外 +function isVisible(bounds: WindowState): boolean { + if ( + bounds.width === undefined || + bounds.height === undefined || + bounds.x === undefined || + bounds.y === undefined + ) { + return false; + } + const displays = screen.getAllDisplays(); + return displays.some((display) => { + const { x, y, width, height } = display.workArea; + const horizontally = + bounds.x < x + width && bounds.x + bounds.width > x; + const vertically = + bounds.y < y + height && bounds.y + bounds.height > y; + return horizontally && vertically; + }); +} + +function loadWindowState(): WindowState { + try { + const file = getWindowStatePath(); + if (fs.existsSync(file)) { + const parsed = JSON.parse( + fs.readFileSync(file, "utf-8"), + ) as WindowState; + if ( + parsed.width !== undefined && + parsed.height !== undefined && + parsed.width >= MIN_WINDOW_SIZE.width && + parsed.height >= MIN_WINDOW_SIZE.height && + isVisible(parsed) + ) { + return parsed; + } + logger.warn({ parsed }, "已保存的窗口状态无效,使用默认尺寸"); + } + } catch (err) { + logger.warn({ err }, "读取窗口状态失败,使用默认尺寸"); + } + return {}; +} + +function saveWindowState(state: WindowState): void { + try { + fs.writeFileSync(getWindowStatePath(), JSON.stringify(state)); + logger.info({ state }, "已保存窗口状态"); + } catch (err) { + logger.warn({ err }, "保存窗口状态失败"); + } +} + +let saveBoundsTimer: NodeJS.Timeout | null = null; +function scheduleSaveBounds(winInstance: BrowserWindow): void { + if (saveBoundsTimer) clearTimeout(saveBoundsTimer); + saveBoundsTimer = setTimeout(() => { + if (winInstance.isDestroyed()) return; + const { width, height, x, y } = winInstance.getBounds(); + saveWindowState({ + width, + height, + x, + y, + maximized: winInstance.isMaximized(), + }); + }, 400); +} + async function createWindow() { + const saved = loadWindowState(); const mainWindow = new BrowserWindow({ title: "星火应用商店", - width: 1366, - height: 768, + width: saved.width ?? DEFAULT_WINDOW_SIZE.width, + height: saved.height ?? DEFAULT_WINDOW_SIZE.height, + x: saved.x, + y: saved.y, + minWidth: MIN_WINDOW_SIZE.width, + minHeight: MIN_WINDOW_SIZE.height, frame: false, autoHideMenuBar: true, icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"), @@ -297,6 +398,18 @@ async function createWindow() { }); win = mainWindow; + // 恢复上一次的最大化状态 + if (saved.maximized) { + mainWindow.maximize(); + } + logger.info({ saved }, "已恢复窗口状态"); + + // 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复 + // 位置/最大化变化由主进程事件保存;尺寸变化由渲染端 DOM resize 经 IPC 兜底保存 + mainWindow.on("moved", () => scheduleSaveBounds(mainWindow)); + mainWindow.on("maximize", () => scheduleSaveBounds(mainWindow)); + mainWindow.on("unmaximize", () => scheduleSaveBounds(mainWindow)); + if (VITE_DEV_SERVER_URL) { // #298 mainWindow.loadURL(VITE_DEV_SERVER_URL); @@ -330,6 +443,15 @@ async function createWindow() { mainWindow.on("close", (event) => { if (allowAppExit) { + // 真正退出前同步保存最终窗口尺寸(防抖可能尚未触发) + const { width, height, x, y } = mainWindow.getBounds(); + saveWindowState({ + width, + height, + x, + y, + maximized: mainWindow.isMaximized(), + }); return; } diff --git a/src/App.vue b/src/App.vue index 3c83aaa2..f8b1a4ef 100644 --- a/src/App.vue +++ b/src/App.vue @@ -73,7 +73,9 @@ :category-counts="categoryCounts" @select-category="selectSubCategory" /> -
+
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue"; -import axios from "axios"; +import axios, { AxiosError } from "axios"; import pino from "pino"; import AppSidebar from "./components/AppSidebar.vue"; import AppHeader from "./components/AppHeader.vue"; @@ -362,10 +360,9 @@ import { setAuthSession, } from "./global/authState"; import { - getAllowedInstalledOrigin, getEffectiveStoreFilter, - getDefaultInstalledOrigin, isOriginEnabled, + isOriginUsable, } from "./modules/storeFilter"; import { createUpdateCenterStore } from "./modules/updateCenter"; import { @@ -424,7 +421,16 @@ const fetchWithRetry = async ( const response = await axiosInstance.get(url); return response.data; } catch (error) { - if (retries > 0) { + const axiosError = error as AxiosError; + const status = axiosError.response?.status; + // 仅对网络错误(无响应)、服务端 5xx 错误或超时进行重试; + // 4xx(如 404/400)属于明确的客户端错误,直接抛出以避免无谓重试。 + const isNetworkError = status === undefined; + const isServerError = typeof status === "number" && status >= 500; + const isTimeout = + axiosError.code === "ECONNABORTED" || axiosError.code === "ETIMEDOUT"; + + if (retries > 0 && (isNetworkError || isServerError || isTimeout)) { await new Promise((resolve) => setTimeout(resolve, delay)); return fetchWithRetry(url, retries - 1, delay * 2); } @@ -432,6 +438,44 @@ const fetchWithRetry = async ( } }; +// 渲染进程从 IPC 拿到的 result.apps 实际类型为 any(ipcRenderer.invoke 返回 Promise), +// 直接断言成 InstalledAppInfo[] 会绕过运行时类型检查。后端字段缺失时会引发运行时错误。 +// 此守卫仅校验本项目实际使用的关键字段,后端字段缺失时跳过即可,避免整批失败。 +const isInstalledAppInfo = (value: unknown): value is InstalledAppInfo => { + if (typeof value !== "object" || value === null) return false; + const v = value as Partial; + return ( + typeof v.pkgname === "string" && + typeof v.name === "string" && + typeof v.version === "string" && + typeof v.arch === "string" && + (v.origin === "spark" || v.origin === "apm") && + typeof v.flags === "string" && + typeof v.isDependency === "boolean" && + // icon 在类型中为可选字段(string | undefined),需显式校验其类型, + // 避免非字符串值进入下游 app.icon || "" 触发隐式转换异常 + (typeof v.icon === "string" || v.icon === undefined) + ); +}; + +// 单来源已安装查询超时时间:某个来源(如 APM)响应极慢或挂起时, +// 不应阻塞其它来源整体返回,超时后该来源标记为失败并走 warning/error 流程。 +const LIST_INSTALLED_TIMEOUT_MS = 15000; + +// 为 Promise 增加超时控制:超时即 reject,配合 Promise.allSettled 让单来源失败不影响其它来源。 +const withTimeout = ( + promise: Promise, + ms: number, + label: string, +): Promise => { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label} 超时(${ms}ms)`)), ms), + ), + ]); +}; + // 响应式状态 const themeMode = ref<"light" | "dark" | "auto">("auto"); const systemIsDark = ref( @@ -468,10 +512,13 @@ const loading = ref(true); const showDownloadDetailModal = ref(false); const currentDownload: Ref = ref(null); const showInstalledModal = ref(false); -const activeInstalledOrigin = ref<"apm" | "spark">("apm"); const installedApps = ref([]); const installedLoading = ref(false); const installedError = ref(""); +// 部分来源失败(如 Spark/APM 之一不可用)时使用更轻量的 warning 提示,避免与已有列表同时呈现红色致命错误条造成 UX 混淆 +const installedWarning = ref(""); +// refreshInstalledApps 的代次计数器,用于异步竞态防护:新进入一次刷新自增,await 后若代次已变则放弃本轮写入 +const installedRefreshGeneration = ref(0); const updateCenterStore = createUpdateCenterStore(); const showUninstallModal = ref(false); const uninstallTargetApp: Ref = ref(null); @@ -1379,131 +1426,203 @@ const confirmMigrationStart = async () => { }; const openInstalledModal = () => { - const defaultOrigin = getDefaultInstalledOrigin( - storeFilter.value, - availableSources.value, - ); - if (!defaultOrigin) { + if ( + getEffectiveStoreFilter(storeFilter.value, availableSources.value) === null + ) { return; } showInstalledModal.value = true; - activeInstalledOrigin.value = - getAllowedInstalledOrigin( - storeFilter.value, - activeInstalledOrigin.value, - availableSources.value, - ) ?? defaultOrigin; refreshInstalledApps(); }; const closeInstalledModal = () => { showInstalledModal.value = false; + // 关闭模态框时同步清空错误 / 警告,避免下次打开时残留过期状态 + installedError.value = ""; + installedWarning.value = ""; }; -const handleSwitchOrigin = (origin: "apm" | "spark") => { - activeInstalledOrigin.value = - getAllowedInstalledOrigin( - storeFilter.value, - origin, - availableSources.value, - ) ?? activeInstalledOrigin.value; - refreshInstalledApps(); +// 根据当前启动模式和系统可用性,确定需要查询哪些 origin 的已安装应用 +const resolveInstalledOrigins = (): Array<"spark" | "apm"> => { + const origins: Array<"spark" | "apm"> = []; + if (isOriginUsable(storeFilter.value, "spark", availableSources.value)) { + origins.push("spark"); + } + if (isOriginUsable(storeFilter.value, "apm", availableSources.value)) { + origins.push("apm"); + } + return origins; }; const refreshInstalledApps = async () => { + // 异步竞态防护:进入时若模态框已关闭(极小概率),直接放弃本轮请求 + if (!showInstalledModal.value) return; installedLoading.value = true; installedError.value = ""; + installedWarning.value = ""; + // 代次计数器:每次进入自增;await 之后若代次已变(新一轮刷新 / 关闭重开),丢弃本轮结果 + const generation = ++installedRefreshGeneration.value; try { - const origin = getAllowedInstalledOrigin( - storeFilter.value, - activeInstalledOrigin.value, - availableSources.value, + const origins = resolveInstalledOrigins(); + // Spark 已安装列表依赖商店目录(apps.value)来枚举包名。 + // 仅当目录中存在可枚举的 Spark 包时才查询 Spark, + // 否则空目录会触发"全量扫描整个系统"的陷阱导致列表被全部跳过而误报为空。 + const sparkPkgnameList = apps.value + .filter((a) => a.origin === "spark") + .map((a) => a.pkgname); + const effectiveOrigins = origins.filter( + (o) => o !== "spark" || sparkPkgnameList.length > 0, ); - if (!origin) { + + if (effectiveOrigins.length === 0) { installedApps.value = []; - installedError.value = "当前系统不可用应用管理功能"; + // 目录尚未加载完成(但来源可用)时给出过渡提示,待目录加载后会自动重查 + installedError.value = + apps.value.length === 0 + ? "正在加载应用目录,请稍候…" + : "当前系统不可用应用管理功能"; return; } - activeInstalledOrigin.value = origin; + // 并行查询每个 origin 的已安装应用: + // 用 allSettled + 每源超时,避免单一来源(如 APM)响应慢/挂起阻塞整体; + // 超时或失败的来源在下方循环标记为 failedOrigin,不影响其它来源结果。 + const results = await Promise.allSettled( + effectiveOrigins.map((origin) => + withTimeout( + window.ipcRenderer.invoke("list-installed", { + origin, + pkgnameList: origin === "spark" ? sparkPkgnameList : undefined, + }), + LIST_INSTALLED_TIMEOUT_MS, + `${origin} list-installed`, + ), + ), + ); - if (!isOriginEnabled(storeFilter.value, origin)) { - installedApps.value = []; - installedError.value = `当前启动模式已禁用 ${origin === "spark" ? "Spark" : "APM"} 软件管理`; - return; - } + // 异步结束后的回调阶段重新校验代次与模态可见性,避免陈旧竞态写入 ref + if (generation !== installedRefreshGeneration.value) return; + if (!showInstalledModal.value) return; - // Spark 优化:只检查远端商店目录中的应用,避免全量扫描 - let pkgnameList: string[] | undefined; - if (origin === "spark") { - pkgnameList = apps.value - .filter((a) => a.origin === "spark") - .map((a) => a.pkgname); - } + const combinedApps: App[] = []; + const failedOrigins: string[] = []; - const result = await window.ipcRenderer.invoke("list-installed", { - origin, - pkgnameList, - }); - if (!result?.success) { - installedApps.value = []; - installedError.value = result?.message || "读取已安装应用失败"; - return; - } - - installedApps.value = []; - for (const app of result.apps) { - // Find matching remote app to enrich data. We look exactly for that origin. - let appInfo = apps.value.find( - (a) => a.pkgname === app.pkgname && a.origin === origin, - ); - - if (origin === "spark" && !appInfo) { - // Only show Spark packages that exist in the App Store catalogue + for (let i = 0; i < effectiveOrigins.length; i++) { + const origin = effectiveOrigins[i]; + const settled = results[i]; + // allSettled: rejected(超时/异常)或 success=false 都视为该来源失败 + if (settled.status !== "fulfilled" || !settled.value?.success) { + failedOrigins.push(origin); continue; } + const result = settled.value; - if (appInfo) { - appInfo.flags = app.flags; - appInfo.arch = app.arch; - appInfo.currentStatus = "installed"; - appInfo.isDependency = app.isDependency; - } else { - // 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象 - appInfo = { - name: app.name || app.pkgname, - pkgname: app.pkgname, - version: app.version, - category: "unknown", - tags: "", - more: "", - filename: "", - torrent_address: "", - author: "", - contributor: "", - website: "", - update: "", - size: "", - img_urls: [], - icons: app.icon || "", - origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"), - currentStatus: "installed", - arch: app.arch, - flags: app.flags, - isDependency: app.isDependency, - }; + const appList = Array.isArray(result?.apps) ? result.apps : []; + for (const rawApp of appList) { + // 运行时类型守卫,避免后端字段缺失造成下游访问 undefined 抛出 + if (!isInstalledAppInfo(rawApp)) continue; + const app = rawApp; + + // Find matching remote app to enrich data. We look exactly for that origin. + let appInfo = apps.value.find( + (a) => a.pkgname === app.pkgname && a.origin === origin, + ); + + if (origin === "spark" && !appInfo) { + // Only show Spark packages that exist in the App Store catalogue + continue; + } + + if (appInfo) { + appInfo.flags = app.flags; + appInfo.arch = app.arch; + appInfo.currentStatus = "installed"; + appInfo.isDependency = app.isDependency; + } else { + // 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象 + appInfo = { + name: app.name || app.pkgname, + pkgname: app.pkgname, + version: app.version, + category: "unknown", + tags: "", + more: "", + filename: "", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + img_urls: [], + icons: app.icon || "", + origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"), + currentStatus: "installed", + arch: app.arch, + flags: app.flags, + isDependency: app.isDependency, + }; + } + combinedApps.push(appInfo); + } + } + + installedApps.value = combinedApps; + + // 部分来源失败使用轻量 warning(不与列表同时呈现红色致命错误条),致命/全失败仍用 error + if (failedOrigins.length > 0) { + const labels = failedOrigins + .map((o) => (o === "spark" ? "Spark" : "APM")) + .join("、"); + if (combinedApps.length > 0) { + installedWarning.value = `部分来源加载失败(${labels}),已安装列表可能不完整`; + } else { + installedError.value = `读取${labels}已安装应用失败`; } - installedApps.value.push(appInfo); } } catch (error: unknown) { + if (generation !== installedRefreshGeneration.value) return; + if (!showInstalledModal.value) return; installedApps.value = []; installedError.value = (error as Error)?.message || "读取已安装应用失败"; } finally { - installedLoading.value = false; + // 仅最末一代次负责清理 loading,防止陈旧代次提前关闭 loading 影响后续刷新 + if (generation === installedRefreshGeneration.value) { + installedLoading.value = false; + } } }; +// 应用目录(apps.value)可能在打开"已安装应用"模态框之后才加载完成。 +// 当目录长度变化、且模态框处于打开状态时,自动重查已安装应用,避免列表一直为空。 +// 注意:上一版仅在 prevLen===0→len>0 触发,会遗漏目录后续更新(例如分类切换触发目录重建)。 +// 现改为 len 任意 >0 的正向变化都允许触发;密集分批推送由下方 300ms 防抖合并最后一次写入。 +// 每次变化都先自增 installedRefreshGeneration:即使上一轮刷新仍在加载中,也会立即失效, +// 避免用陈旧目录数据覆盖已安装列表(清理不单纯依赖定时器,代次校验兜底)。 +let refreshDebounceTimer: ReturnType | null = null; +watch( + () => apps.value.length, + (len) => { + if (!showInstalledModal.value || len <= 0) { + return; + } + installedRefreshGeneration.value++; + if (refreshDebounceTimer !== null) { + clearTimeout(refreshDebounceTimer); + } + refreshDebounceTimer = setTimeout(() => { + refreshDebounceTimer = null; + void refreshInstalledApps(); + }, 300); + }, +); +onUnmounted(() => { + if (refreshDebounceTimer !== null) { + clearTimeout(refreshDebounceTimer); + } +}); + const mapInstalledAppToCatalogApp = ( app: InstalledAppInfo, origin: "spark" | "apm", @@ -1573,8 +1692,11 @@ const refreshFavoriteInstalledApps = async (): Promise => { }); if (!result?.success) return; - for (const app of result.apps as InstalledAppInfo[]) { - const appInfo = mapInstalledAppToCatalogApp(app, origin); + const appList = Array.isArray(result?.apps) ? result.apps : []; + for (const rawApp of appList) { + // 运行时类型守卫:避免后端字段缺失时下游访问 undefined + if (!isInstalledAppInfo(rawApp)) continue; + const appInfo = mapInstalledAppToCatalogApp(rawApp, origin); if (appInfo) refreshedApps.push(appInfo); } }), @@ -1916,8 +2038,11 @@ const refreshInstalledSyncCandidates = async ( }); if (!result?.success) return; - for (const app of result.apps as InstalledAppInfo[]) { - const appInfo = mapInstalledAppToCatalogApp(app, origin); + const appList = Array.isArray(result?.apps) ? result.apps : []; + for (const rawApp of appList) { + // 运行时类型守卫:避免后端字段缺失时下游访问 undefined + if (!isInstalledAppInfo(rawApp)) continue; + const appInfo = mapInstalledAppToCatalogApp(rawApp, origin); if (appInfo) refreshedApps.push(appInfo); } }), @@ -2450,7 +2575,9 @@ const openDownloadedApp = (pkgname: string, origin?: "spark" | "apm") => { // openApmStoreUrl(`apmstore://launch?pkg=${encodedPkg}`, { // fallbackText: `打开应用: ${download.pkgname}` // }); - window.ipcRenderer.invoke("launch-app", { pkgname, origin }); + window.ipcRenderer + .invoke("launch-app", { pkgname, origin }) + .catch((err) => logger.error("启动应用失败 (launch-app):", err)); }; const loadCategories = async () => { @@ -2794,6 +2921,15 @@ const handleSearchFocus = () => { if (activeTab.value === "home") activeTab.value = "all"; }; +// 窗口尺寸变化(含无边框窗口鼠标拉边角)时,防抖通知主进程保存当前尺寸 +let saveBoundsTimer: number | undefined; +const handleWindowResize = () => { + if (saveBoundsTimer) clearTimeout(saveBoundsTimer); + saveBoundsTimer = window.setTimeout(() => { + void window.ipcRenderer.invoke("save-window-bounds"); + }, 400); +}; + // 生命周期钩子 onMounted(async () => { initTheme(); @@ -2806,6 +2942,9 @@ onMounted(async () => { handleHashChange(); window.addEventListener("hashchange", handleHashChange); + // 窗口尺寸变化(含无边框窗口鼠标拉边角)时,防抖通知主进程保存当前尺寸 + window.addEventListener("resize", handleWindowResize); + try { systemInfo.value = await window.ipcRenderer.invoke("get-system-info"); } catch (error: unknown) { @@ -2827,10 +2966,6 @@ onMounted(async () => { apmAvailable.value = await window.ipcRenderer.invoke("check-apm-available"); } - activeInstalledOrigin.value = - getDefaultInstalledOrigin(storeFilter.value, availableSources.value) ?? - "spark"; - await loadCategories(); await loadSidebarConfig(); @@ -2988,6 +3123,7 @@ onUnmounted(() => { "install-complete", handleInstallCompleteForDownloadRecord, ); + window.removeEventListener("resize", handleWindowResize); }); // 观察器 diff --git a/src/__tests__/unit/InstalledAppsModal.test.ts b/src/__tests__/unit/InstalledAppsModal.test.ts index f9cac7a8..536f7370 100644 --- a/src/__tests__/unit/InstalledAppsModal.test.ts +++ b/src/__tests__/unit/InstalledAppsModal.test.ts @@ -26,21 +26,20 @@ const createApp = (overrides: Partial = {}): App => ({ }); describe("InstalledAppsModal", () => { + const baseProps = { + show: true, + apps: [] as App[], + loading: false, + error: "", + warning: "", + loggedIn: false, + syncing: false, + syncMessage: "", + }; + it("keeps scroll chaining inside the modal list", () => { const { container } = render(InstalledAppsModal, { - props: { - show: true, - apps: [], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", - }, + props: baseProps, }); expect(screen.getByText("已安装应用")).toBeTruthy(); @@ -52,17 +51,8 @@ describe("InstalledAppsModal", () => { it("renders open and detail actions for a store-backed installed app", () => { render(InstalledAppsModal, { props: { - show: true, + ...baseProps, apps: [createApp()], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", }, }); @@ -70,20 +60,42 @@ describe("InstalledAppsModal", () => { expect(screen.getByRole("button", { name: "查看详情" })).toBeTruthy(); }); + it("renders the spark origin tag for spark apps", () => { + render(InstalledAppsModal, { + props: { + ...baseProps, + apps: [createApp({ origin: "spark", name: "Spark Notes" })], + }, + }); + + // 精确匹配应用行内的来源标签(页头统计区也有 "Spark" 文案,getAllByText 过于宽泛) + expect(screen.getByTestId("origin-tag-spark")).toBeTruthy(); + }); + + it("renders the APM origin tag for APM apps", () => { + render(InstalledAppsModal, { + props: { + ...baseProps, + apps: [ + createApp({ + origin: "apm", + name: "APM Container", + pkgname: "amber-pm-container", + version: "1.0.0", + }), + ], + }, + }); + + // 精确匹配应用行内的来源标签(页头统计区也有 "APM" 文案,getAllByText 过于宽泛) + expect(screen.getByTestId("origin-tag-apm")).toBeTruthy(); + }); + it("emits open-app when clicking 打开", async () => { const rendered = render(InstalledAppsModal, { props: { - show: true, + ...baseProps, apps: [createApp()], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", }, }); @@ -98,17 +110,8 @@ describe("InstalledAppsModal", () => { it("emits open-detail when clicking 查看详情", async () => { const rendered = render(InstalledAppsModal, { props: { - show: true, + ...baseProps, apps: [createApp()], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", }, }); @@ -123,17 +126,8 @@ describe("InstalledAppsModal", () => { it("shows 查看详情 for metadata-rich unknown-category apps", () => { render(InstalledAppsModal, { props: { - show: true, + ...baseProps, apps: [createApp({ category: "unknown", more: "Has store metadata" })], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", }, }); @@ -143,17 +137,8 @@ describe("InstalledAppsModal", () => { it("hides 查看详情 for unknown-category apps", () => { render(InstalledAppsModal, { props: { - show: true, + ...baseProps, apps: [createApp({ category: "unknown" })], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, - loggedIn: false, - syncing: false, - syncMessage: "", }, }); @@ -162,19 +147,7 @@ describe("InstalledAppsModal", () => { 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, - syncMessage: "", - }, + props: baseProps, }); await fireEvent.click(screen.getByRole("button", { name: "同步到账号" })); @@ -186,17 +159,8 @@ describe("InstalledAppsModal", () => { 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, + ...baseProps, loggedIn: true, - syncing: false, - syncMessage: "", }, }); @@ -210,17 +174,9 @@ describe("InstalledAppsModal", () => { it("disables sync button while syncing", () => { render(InstalledAppsModal, { props: { - show: true, - apps: [], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, + ...baseProps, loggedIn: true, syncing: true, - syncMessage: "", }, }); @@ -230,20 +186,94 @@ describe("InstalledAppsModal", () => { it("shows account sync feedback in the installed apps modal", () => { render(InstalledAppsModal, { props: { - show: true, - apps: [], - loading: false, - error: "", - activeOrigin: "spark", - storeFilter: "both", - sparkAvailable: true, - apmAvailable: true, + ...baseProps, loggedIn: true, - syncing: false, syncMessage: "同步完成", }, }); expect(screen.getByText("同步完成")).toBeTruthy(); }); + + it("filters installed apps by search query (name match)", async () => { + render(InstalledAppsModal, { + props: { + ...baseProps, + apps: [ + createApp({ name: "Spark Notes", pkgname: "spark-notes" }), + createApp({ + name: "Visual Studio Code", + pkgname: "code", + category: "dev", + more: "https://code.visualstudio.com/", + }), + ], + }, + }); + + // 初始两条都在 + expect(screen.getByText("Spark Notes")).toBeTruthy(); + expect(screen.getByText("Visual Studio Code")).toBeTruthy(); + + const input = screen.getByPlaceholderText("搜索已安装应用…"); + await fireEvent.update(input, "code"); + + // "code" 只匹配到 pkgname 为 "code" 的项(名称不区分大小写) + expect(screen.queryByText("Spark Notes")).toBeNull(); + expect(screen.getByText("Visual Studio Code")).toBeTruthy(); + }); + + it("filters installed apps by search query (case-insensitive)", async () => { + render(InstalledAppsModal, { + props: { + ...baseProps, + apps: [ + createApp({ name: "钉钉", pkgname: "com.alibaba.dingtalk" }), + ], + }, + }); + + const input = screen.getByPlaceholderText("搜索已安装应用…"); + await fireEvent.update(input, "DINGTALK"); + + // 包名大写不区分大小写匹配 + expect(screen.getByText("钉钉")).toBeTruthy(); + }); + + it("shows a no-match hint when search query has no results", async () => { + render(InstalledAppsModal, { + props: { + ...baseProps, + apps: [createApp({ name: "Spark Notes" })], + }, + }); + + const input = screen.getByPlaceholderText("搜索已安装应用…"); + await fireEvent.update(input, "不存在的关键字xyz"); + + expect(screen.queryByText("Spark Notes")).toBeNull(); + expect(screen.getByText(/未找到匹配/)).toBeTruthy(); + }); + + it("clears the search when clicking the clear button", async () => { + render(InstalledAppsModal, { + props: { + ...baseProps, + apps: [createApp()], + }, + }); + + const input = screen.getByPlaceholderText( + "搜索已安装应用…", + ) as HTMLInputElement; + await fireEvent.update(input, "code"); + expect(input.value).toBe("code"); + + // 清除按钮存在(仅在有内容时显示) + const clearBtn = screen.getByRole("button", { name: "清除搜索" }); + await fireEvent.click(clearBtn); + + expect(input.value).toBe(""); + expect(screen.getByText("Spark Notes")).toBeTruthy(); + }); }); diff --git a/src/components/AppGrid.vue b/src/components/AppGrid.vue index d75216ad..a2dcefcb 100644 --- a/src/components/AppGrid.vue +++ b/src/components/AppGrid.vue @@ -21,8 +21,8 @@
{ .non-virtual-scroller { height: 100%; overflow-y: auto; + margin: -24px -16px; /* 抵消父容器的 px-4 py-6 */ } @media (min-width: 1024px) { .scroller { margin: -24px -40px; /* 抵消父容器的 lg:px-10 */ } + + .non-virtual-scroller { + margin: -24px -40px; /* 抵消父容器的 lg:px-10 */ + } } .grid-row { diff --git a/src/components/InstalledAppsModal.vue b/src/components/InstalledAppsModal.vue index a7d28b32..174f42d3 100644 --- a/src/components/InstalledAppsModal.vue +++ b/src/components/InstalledAppsModal.vue @@ -14,71 +14,142 @@ @wheel="onOverlayWheel" >
-
+

已安装应用

管理本机安装的应用程序

+
+
+ +
+ + + +
+ {{ apmCount }} + APM +
+
+ + + +
+ + + +
+ {{ sparkCount }} + Spark +
+
+ + + +
+ + + +
+ {{ totalCount }} + 总数 +
+
+
+ +
+ + + +
+
- -
- - -