mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-09-20 21:50:11 +08:00
fix: 安全与健壮性排查修复(IPC 类型校验/AbortSignal/空名去重/窗口 await)
阻断项: - 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<string,unknown> 收紧为 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 通过。
This commit is contained in:
+54
-24
@@ -431,13 +431,16 @@ const axiosInstance = axios.create({
|
||||
|
||||
const fetchWithRetry = async <T,>(
|
||||
url: string,
|
||||
signal?: AbortSignal,
|
||||
retries = 3,
|
||||
delay = 1000,
|
||||
): Promise<T> => {
|
||||
try {
|
||||
const response = await axiosInstance.get<T>(url);
|
||||
const response = await axiosInstance.get<T>(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 <T,>(
|
||||
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<any>),
|
||||
// 直接断言成 InstalledAppInfo[] 会绕过运行时类型检查。后端字段缺失时会引发运行时错误。
|
||||
// 此守卫仅校验本项目实际使用的关键字段,后端字段缺失时跳过即可,避免整批失败。
|
||||
@@ -926,28 +934,36 @@ const openDetailFromInstalled = (app: App) => {
|
||||
openDetail({ ...app, _fromInstalled: true });
|
||||
};
|
||||
|
||||
const openDetail = async (app: App | Record<string, unknown>) => {
|
||||
// openDetail 输入类型:完整 App 或仅含必要字段的轻量对象(含内部来源标记)
|
||||
interface OpenDetailInput extends Partial<App> {
|
||||
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<string, unknown>).pkgname as string;
|
||||
const category =
|
||||
((app as Record<string, unknown>).category as string) || "unknown";
|
||||
// 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息)
|
||||
const fromHomeView = (app as Record<string, unknown>)._fromHomeView === true;
|
||||
const fromDeepLink = (app as Record<string, unknown>)._fromDeepLink === true;
|
||||
// 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型)
|
||||
const fromInstalled = (app as Record<string, unknown>)._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<string, unknown>).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<string, unknown>[]) : [];
|
||||
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<Record<string, string>[]>(path)) || [];
|
||||
(await fetchWithRetry<Record<string, string>[]>(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<AppJson[]>(path)
|
||||
fetchWithRetry<AppJson[]>(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<AppJson[]>(path)
|
||||
fetchWithRetry<AppJson[]>(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<AppJson[]>(path);
|
||||
const categoryApps = await fetchWithRetry<AppJson[]>(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",
|
||||
|
||||
Reference in New Issue
Block a user