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 @@