From 4f772ba7ebf7d830c0a809de928bda9c5fc0f5a1 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Mon, 3 Aug 2026 11:24:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=89=E5=85=A8=E4=B8=8E=E5=81=A5?= =?UTF-8?q?=E5=A3=AE=E6=80=A7=E6=8E=92=E6=9F=A5=E4=BF=AE=E5=A4=8D=EF=BC=88?= =?UTF-8?q?IPC=20=E7=B1=BB=E5=9E=8B=E6=A0=A1=E9=AA=8C/AbortSignal/?= =?UTF-8?q?=E7=A9=BA=E5=90=8D=E5=8E=BB=E9=87=8D/=E7=AA=97=E5=8F=A3=20await?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阻断项: - install-manager: uninstall-installed / remove-installed / check-installed 由 any 改为 unknown + parseAppPayload 严格解析(PKGNAME_PATTERN 校验 pkgname,origin 仅限 spark|apm),消除命令注入风险;parseAppPayload/PKGNAME_PATTERN 提升至模块顶部复用并避免 TDZ - App.vue openDetail: 参数由 Record 收紧为 App | OpenDetailInput,新增 pkgname 运行时校验,避免访问 undefined 属性 改进项: - App.vue homeLinks 去重:跳过空名称避免污染 seenNames,并对 links 数组与 HomeLink 必需字段做校验 - App.vue fetchWithRetry: 新增 AbortSignal 参数,递归链路传递,组件卸载时 rootAbortController.abort() 取消未完成任务 - index.ts showAndFocusMainWindow: 改为 async 并 await createWindow,调用点用 void 修饰避免 floating promise vue-tsc --noEmit 通过。 --- electron/main/backend/install-manager.ts | 64 ++++++++++++------- electron/main/index.ts | 13 ++-- src/App.vue | 78 ++++++++++++++++-------- 3 files changed, 102 insertions(+), 53 deletions(-) diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index 5eee6ac7..02bea18e 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -12,6 +12,26 @@ import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "./superuser"; const logger = pino({ name: "install-manager" }); +// 包名白名单:仅允许合法包名字符,杜绝命令注入(spawn 用 shell:false 仍须校验)。 +const PKGNAME_PATTERN = /^[a-zA-Z0-9._+-]+$/; + +// 解析并校验应用类 IPC 的 payload(可能是旧版字符串或对象)。 +// 返回规范化后的 { pkgname, origin },pkgname 非法时返回 null。 +const parseAppPayload = ( + payload: unknown, +): { pkgname: string; origin: "spark" | "apm" } | null => { + if (typeof payload === "string") { + if (!PKGNAME_PATTERN.test(payload)) return null; + return { pkgname: payload, origin: "spark" }; + } + if (typeof payload !== "object" || payload === null) return null; + const p = payload as Record; + const pkgname = typeof p.pkgname === "string" ? p.pkgname : ""; + if (!PKGNAME_PATTERN.test(pkgname)) return null; + const origin: "spark" | "apm" = p.origin === "apm" ? "apm" : "spark"; + return { pkgname, origin }; +}; + const getStoreFilterFromArgv = (): "spark" | "apm" | "both" => { const argv = process.argv; const noApm = argv.includes("--no-apm"); @@ -815,15 +835,13 @@ async function runInstallPhase(task: InstallTask) { } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -ipcMain.handle("check-installed", async (_event, payload: any) => { - const pkgname = typeof payload === "string" ? payload : payload.pkgname; - const origin = typeof payload === "string" ? "spark" : payload.origin; - - if (!pkgname) { - logger.warn("check-installed missing pkgname"); +ipcMain.handle("check-installed", async (_event, payload: unknown) => { + const parsed = parseAppPayload(payload); + if (!parsed) { + logger.warn("check-installed invalid payload"); return false; } + const { pkgname, origin } = parsed; logger.info(`检查应用是否已安装: ${pkgname} (来源: ${origin})`); @@ -890,13 +908,12 @@ ipcMain.handle("check-installed", async (_event, payload: any) => { ipcMain.on("remove-installed", async (_event, payload) => { const webContents = _event.sender; - const pkgname = typeof payload === "string" ? payload : payload.pkgname; - const origin = typeof payload === "string" ? "spark" : payload.origin; - - if (!pkgname) { - logger.warn("remove-installed missing pkgname"); + const parsed = parseAppPayload(payload); + if (!parsed) { + logger.warn("remove-installed invalid payload"); return; } + const { pkgname, origin } = parsed; logger.info(`卸载已安装应用: ${pkgname} (来源: ${origin})`); let execCommand = ""; @@ -1243,14 +1260,18 @@ ipcMain.handle("show-apm-install-dialog", async (event) => { }); // eslint-disable-next-line @typescript-eslint/no-explicit-any -ipcMain.handle("uninstall-installed", async (_event, payload: any) => { - const pkgname = typeof payload === "string" ? payload : payload.pkgname; - const origin = typeof payload === "string" ? "spark" : payload.origin; - - if (!pkgname) { - logger.warn("uninstall-installed missing pkgname"); - return { success: false, message: "missing pkgname" }; - } +ipcMain.handle( + "uninstall-installed", + async ( + _event, + payload: unknown, + ): Promise<{ success: boolean; message?: string }> => { + const parsed = parseAppPayload(payload); + if (!parsed) { + logger.warn("uninstall-installed invalid payload"); + return { success: false, message: "invalid payload" }; + } + const { pkgname, origin } = parsed; const superUserCmd = await checkSuperUserCommand(); const execCommand = superUserCmd || SHELL_CALLER_PATH; @@ -1287,9 +1308,6 @@ interface LaunchAppPayload { origin?: "spark" | "apm"; } -// 合法包名字符(Debian 包名规范 + Spark 应用包名常见字符), -// 用于拦截包含特殊字符的非法输入,避免命令注入。 -const PKGNAME_PATTERN = /^[a-zA-Z0-9._+-]+$/; ipcMain.handle( "launch-app", diff --git a/electron/main/index.ts b/electron/main/index.ts index 6d6e1dc5..9a37e241 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -272,9 +272,10 @@ const requestApplicationExit = (): void => { app.quit(); }; -const showAndFocusMainWindow = (): void => { +const showAndFocusMainWindow = async (): Promise => { if (!win || win.isDestroyed()) { - createWindow(); + // 等待窗口创建完成,创建失败时调用方可通过异常感知 + await createWindow(); return; } @@ -643,11 +644,11 @@ app.on("window-all-closed", () => { }); app.on("second-instance", () => { - showAndFocusMainWindow(); + void showAndFocusMainWindow(); }); app.on("activate", () => { - showAndFocusMainWindow(); + void showAndFocusMainWindow(); }); app.on("will-quit", () => { @@ -700,7 +701,7 @@ app.whenReady().then(() => { { label: "显示主界面", click: () => { - showAndFocusMainWindow(); + void showAndFocusMainWindow(); }, }, { @@ -719,7 +720,7 @@ app.whenReady().then(() => { win.hide(); win.setSkipTaskbar(true); } else { - showAndFocusMainWindow(); + void showAndFocusMainWindow(); } }); }); diff --git a/src/App.vue b/src/App.vue index f37712a6..5220d5e0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -431,13 +431,16 @@ const axiosInstance = axios.create({ const fetchWithRetry = async ( url: string, + signal?: AbortSignal, retries = 3, delay = 1000, ): Promise => { try { - const response = await axiosInstance.get(url); + const response = await axiosInstance.get(url, { signal }); return response.data; } catch (error) { + // 请求被取消(AbortSignal)时直接抛出,不再重试 + if (signal?.aborted) throw error; const axiosError = error as AxiosError; const status = axiosError.response?.status; // 仅对网络错误(无响应)、服务端 5xx 错误或超时进行重试; @@ -448,13 +451,18 @@ const fetchWithRetry = async ( axiosError.code === "ECONNABORTED" || axiosError.code === "ETIMEDOUT"; if (retries > 0 && (isNetworkError || isServerError || isTimeout)) { + // 若已取消则提前退出,避免对已卸载组件发起新请求 + if (signal?.aborted) throw error; await new Promise((resolve) => setTimeout(resolve, delay)); - return fetchWithRetry(url, retries - 1, delay * 2); + return fetchWithRetry(url, signal, retries - 1, delay * 2); } throw error; } }; +// 根级请求取消控制器:组件卸载时 abort,避免对已卸载组件发起/重试请求 +const rootAbortController = new AbortController(); + // 渲染进程从 IPC 拿到的 result.apps 实际类型为 any(ipcRenderer.invoke 返回 Promise), // 直接断言成 InstalledAppInfo[] 会绕过运行时类型检查。后端字段缺失时会引发运行时错误。 // 此守卫仅校验本项目实际使用的关键字段,后端字段缺失时跳过即可,避免整批失败。 @@ -926,28 +934,36 @@ const openDetailFromInstalled = (app: App) => { openDetail({ ...app, _fromInstalled: true }); }; -const openDetail = async (app: App | Record) => { +// openDetail 输入类型:完整 App 或仅含必要字段的轻量对象(含内部来源标记) +interface OpenDetailInput extends Partial { + pkgname?: string; + category?: string; + _fromHomeView?: boolean; + _fromInstalled?: boolean; + _fromDeepLink?: boolean; + origin?: "spark" | "apm"; +} + +const openDetail = async (app: App | OpenDetailInput) => { // 提取 pkgname 和 category(必须存在) - const pkgname = (app as Record).pkgname as string; - const category = - ((app as Record).category as string) || "unknown"; - // 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息) - const fromHomeView = (app as Record)._fromHomeView === true; - const fromDeepLink = (app as Record)._fromDeepLink === true; - // 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型) - const fromInstalled = (app as Record)._fromInstalled === true; - const needFetchFromStore = fromHomeView || fromDeepLink || fromInstalled; + const pkgname = app?.pkgname; if (!pkgname) { - console.warn("openDetail: 缺少 pkgname", app); + console.warn("openDetail called without pkgname"); return; } + const category = app.category || "unknown"; + // 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息) + // 内部来源标记仅存在于轻量输入对象上,按 OpenDetailInput 读取以兼容联合类型 + const marker = app as OpenDetailInput; + const fromHomeView = marker._fromHomeView === true; + const fromDeepLink = marker._fromDeepLink === true; + // 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型) + const fromInstalled = marker._fromInstalled === true; + const needFetchFromStore = fromHomeView || fromDeepLink || fromInstalled; // 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找 // 优先匹配点击来源 origin(例如排行点击 APM 应用时不应误匹配到 Spark 版) - const clickedOrigin = (app as Record).origin as - | "spark" - | "apm" - | undefined; + const clickedOrigin = app.origin as "spark" | "apm" | undefined; let fullApp = filteredApps.value.find( (a) => a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin), @@ -1351,12 +1367,25 @@ const loadHome = async () => { try { const res = await fetch(`${base}/homelinks.json`); if (res.ok) { - const links = await res.json(); + const raw = await res.json(); + // 校验 links 为数组,且每项均为对象(避免后端返回异常结构导致运行时错误) + const links = Array.isArray(raw) ? (raw.filter((x) => x && typeof x === "object") as Record[]) : []; for (const l of links) { - const name = l.Name || l.name || ""; + const name = (l.Name as string) || (l.name as string) || ""; + if (!name) continue; // 跳过空名称,避免空字符串污染 seenNames 与去重逻辑 if (seenNames.has(name)) continue; // 已由更高优先级来源(spark)占据 + // 校验 HomeLink 必需字段,缺失则跳过,避免推入不完整对象导致运行时错误 + const url = (l.Url as string) || (l.url as string) || ""; + const icon = (l.Icon as string) || (l.icon as string) || ""; + if (!url || !icon) continue; seenNames.add(name); - homeLinks.value.push({ ...l, origin: mode }); + homeLinks.value.push({ + ...l, + name, + url, + icon, + origin: mode, + } as HomeLink); } } } catch (e) { @@ -1507,7 +1536,7 @@ const loadHomeListApps = async (entryId: string) => { try { const path = `/${finalArch}${jsonUrl}`; const rawApps = - (await fetchWithRetry[]>(path)) || []; + (await fetchWithRetry[]>(path, rootAbortController.signal)) || []; const apps = parseAppList(rawApps, mode); for (const app of apps) { if (!app.pkgname || seenPkgnames.has(app.pkgname)) continue; @@ -3027,7 +3056,7 @@ const loadTabApps = async (entryId: string) => { const path = `/${finalArch}/${folderName}/${subCat}/applist.json`; logger.info(`加载入口子分类: ${entryId}/${subCat} (来源: ${mode})`); tasks.push( - fetchWithRetry(path) + fetchWithRetry(path, rootAbortController.signal) .then((categoryApps) => (categoryApps || []).map((aj) => normalizeAppJson(aj, subCat, mode), @@ -3045,7 +3074,7 @@ const loadTabApps = async (entryId: string) => { const path = `/${finalArch}/${folderName}/applist.json`; logger.info(`加载入口目录: ${entryId} (来源: ${mode})`); tasks.push( - fetchWithRetry(path) + fetchWithRetry(path, rootAbortController.signal) .then((categoryApps) => (categoryApps || []).map((aj) => normalizeAppJson(aj, folderName, mode), @@ -3097,7 +3126,7 @@ const loadApps = async (onFirstBatch?: () => void) => { const path = `/${finalArch}/${category}/applist.json`; logger.info(`加载分类: ${category} (来源: ${mode})`); - const categoryApps = await fetchWithRetry(path); + const categoryApps = await fetchWithRetry(path, rootAbortController.signal); const normalizedApps = (categoryApps || []).map((appJson) => normalizeAppJson(appJson, category, mode as "spark" | "apm"), @@ -3351,6 +3380,7 @@ onMounted(async () => { }); onUnmounted(() => { + rootAbortController.abort(); updateCenterStore.unbind(); window.ipcRenderer.off( "install-complete",