Files
spark-store/src/composables/useInstalledApps.ts
T
shenmo7192 56a7e11622 fix(install): 修复 APM 未安装时点安装静默无反应,并清理同类接线断裂
主问题:showApmInstallDialog 在 useAppState.ts 被重复声明为独立 ref,
而 processInstall.ts 置位的是 storeConfig.ts 中的另一份实例,导致 APM
未安装时点「安装」既不弹引导弹窗也不创建下载任务(界面毫无反应)。

修复:
- useAppState 改为从 storeConfig 再导出同一实例(消除双实例)
- processInstall 在 install-complete 兜底识别「未安装 APM」失败原因
- 新增回归测试 ApmInstallDialog.test.ts(回退修复后 3/3 失败)

同类「接线断裂」问题全量排查修复(源自 d7bdcf36 拆分 composables):
- 应用下载排行永远为空:loadRanking 在 apps 未填充时空跑且不重试
- 详情页卸载后按钮状态不刷新(丢失的 checkAppInstalled 分支)
- 重试已取消任务复用 id,被旧任务 finally 删除而永久卡在下载中
- remove-installed 的 spawn 缺 error 监听(可致主进程崩溃)且不消费 stderr
- 云端应用列表在来源查询失败时静默丢数据(已安装列表同源问题)
- 忽略更新写入失败无反馈;下载统计上报缺 catch
- isInstalledAppInfo 运行时校验被削弱

清理重构遗留孤儿代码:
- 删除无引用的 AppDetailPage/AccountQuickMenu/TopActions/UpdateCenterCloseConfirm
- 恢复 handleRemoveComplete 语义与 onUnmounted 的 abort/unbind
- 删除孤儿 IPC:list-upgradable/show-apm-install-dialog/uninstall-installed
- 删除孤儿渲染端代码:handleUpgrade/handleRemove/useDownloadItemStatus
  及已无生产者的 queue-install 回灌监听器

验证:vue-tsc 0 error、vite build 通过、单元测试无新增失败
(基线 40 失败 → 35 失败)。

Signed-off-by: shenmo <jifengshenmo@outlook.com>
2026-10-11 12:34:10 +08:00

387 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* useInstalledApps —— 已安装应用列表的查询、合并、卸载触发逻辑。
*
* 从原 App.vue 原样搬移(isInstalledAppInfo / LIST_INSTALLED_TIMEOUT_MS /
* resolveInstalledOrigins / refreshInstalledApps / mapInstalledAppToCatalogApp /
* refreshFavoriteInstalledApps / requestUninstall / openInstalledModal /
* closeInstalledModal / uninstallInstalledApp / installedCloudKeys /
* installedCloudPackageKeys),逻辑零改动。
*
* 共享状态来自 useAppState;withTimeout / rootAbortController 来自 useHttp。
*/
import { computed } from "vue";
import type { App, InstalledAppInfo } from "../global/typedefinition";
import {
apps,
installedApps,
installedLoading,
installedError,
installedWarning,
installedRefreshGeneration,
showInstalledModal,
sparkAvailable,
apmAvailable,
storeFilter,
showUninstallModal,
uninstallTargetApp,
syncCandidateApps,
availableSources,
} from "./useAppState";
import { withTimeout } from "./useHttp";
import {
isOriginUsable,
isOriginEnabled,
getEffectiveStoreFilter,
} from "../modules/storeFilter";
import { cloudItemKey, cloudPackageKey } from "../modules/appListSync";
// 运行时类型守卫(重构前在 App.vue 中校验完整字段,拆分时被削弱成只查
// pkgname/origin,导致字段残缺的行会带着 undefined 流入已安装列表与云端
// 同步条目)。主进程 list-installed 始终按下方结构回传,逐字段校验即可。
const isInstalledAppInfo = (value: unknown): value is InstalledAppInfo => {
if (typeof value !== "object" || value === null) return false;
const v = value as Partial<InstalledAppInfo>;
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 为可选字段,需显式校验类型,避免非字符串值进入下游 app.icon || ""
(typeof v.icon === "string" || v.icon === undefined)
);
};
const LIST_INSTALLED_TIMEOUT_MS = 15000;
// 根据当前启动模式和系统可用性,确定需要查询哪些 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 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 (effectiveOrigins.length === 0) {
installedApps.value = [];
// 目录尚未加载完成(但来源可用)时给出过渡提示,待目录加载后会自动重查
installedError.value =
apps.value.length === 0
? "正在加载应用目录,请稍候…"
: "当前系统不可用应用管理功能";
return;
}
// 并行查询每个 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`,
),
),
);
// 异步结束后的回调阶段重新校验代次与模态可见性,避免陈旧竞态写入 ref
if (generation !== installedRefreshGeneration.value) return;
if (!showInstalledModal.value) return;
const combinedApps: App[] = [];
// 同一 pkgname 可能同时以 APM 与 Spark 两种来源安装,这里聚合其来源集合
const originsByPkg = new Map<string, Set<"spark" | "apm">>();
const failedOrigins: string[] = [];
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;
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,
};
}
// 合并同一 pkgname 在多个来源的安装记录为单条,避免列表出现重复项(相同 pkgname 键冲突)
const existingIdx = combinedApps.findIndex(
(a) => a.pkgname === appInfo.pkgname,
);
if (existingIdx === -1) {
combinedApps.push(appInfo);
}
// 记录该 pkgname 当前这一来源,供卸载时判断走 APM 还是 Spark
const originSet =
originsByPkg.get(appInfo.pkgname) ?? new Set<"spark" | "apm">();
originSet.add(origin);
originsByPkg.set(appInfo.pkgname, originSet);
}
}
// 将来源集合回写到每条已安装应用,供卸载时判断应走 APM 还是 Spark
for (const app of combinedApps) {
const set = originsByPkg.get(app.pkgname);
if (set) app.origins = Array.from(set);
}
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}已安装应用失败`;
}
}
} catch (error: unknown) {
if (generation !== installedRefreshGeneration.value) return;
if (!showInstalledModal.value) return;
installedApps.value = [];
installedError.value = (error as Error)?.message || "读取已安装应用失败";
} finally {
// 仅最末一代次负责清理 loading,防止陈旧代次提前关闭 loading 影响后续刷新
if (generation === installedRefreshGeneration.value) {
installedLoading.value = false;
}
}
};
const mapInstalledAppToCatalogApp = (
app: InstalledAppInfo,
origin: "spark" | "apm",
): App | null => {
const appInfo = apps.value.find(
(catalogApp) =>
catalogApp.pkgname === app.pkgname && catalogApp.origin === origin,
);
if (origin === "spark" && !appInfo) {
return null;
}
if (appInfo) {
appInfo.flags = app.flags;
appInfo.arch = app.arch;
appInfo.currentStatus = "installed";
appInfo.isDependency = app.isDependency;
return appInfo;
}
return {
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 refreshFavoriteInstalledApps = async (): Promise<void> => {
const origins: Array<"spark" | "apm"> = [];
if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
origins.push("spark");
}
if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) {
origins.push("apm");
}
const refreshedApps: App[] = [];
const refreshedOrigins: Array<"spark" | "apm"> = [];
await Promise.all(
origins.map(async (origin) => {
const pkgnameList =
origin === "spark"
? apps.value
.filter((app) => app.origin === "spark")
.map((app) => app.pkgname)
: undefined;
const result = await window.ipcRenderer.invoke("list-installed", {
origin,
pkgnameList,
});
if (!result?.success) return;
// 仅「查询成功」的来源才算已刷新:来源禁用/包管理器缺失时返回
// success:false,此时若仍从列表里剔除该来源的已知应用,会让已安装
// 列表凭空少掉一整类应用(与云端同步丢数据同源)。
refreshedOrigins.push(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);
}
}),
);
const refreshedKeys = new Set(
refreshedApps.map((app) => `${app.origin}:${app.pkgname}`),
);
installedApps.value = [
...installedApps.value.filter(
(app) =>
!refreshedOrigins.includes(app.origin) &&
!refreshedKeys.has(`${app.origin}:${app.pkgname}`),
),
...refreshedApps,
];
};
const requestUninstall = (app: App) => {
uninstallTargetApp.value = app;
showUninstallModal.value = true;
// 注意:不要在此处移除下载记录。卸载尚未确认/完成,提前移除会导致
// 用户取消卸载也丢失记录;实际移除由 remove-complete 成功后统一处理
// (见 App.vue handleRemoveComplete),与重构前的语义一致。
};
const openInstalledModal = () => {
if (
getEffectiveStoreFilter(storeFilter.value, availableSources.value) === null
) {
return;
}
showInstalledModal.value = true;
refreshInstalledApps();
};
const closeInstalledModal = () => {
showInstalledModal.value = false;
// 关闭模态框时同步清空错误 / 警告,避免下次打开时残留过期状态
installedError.value = "";
installedWarning.value = "";
};
const uninstallInstalledApp = (app: App) => {
requestUninstall(app);
};
// 应用在更新中心/详情页的安装状态变更后,刷新已安装列表(由 App.vue 编排调用)
const onUninstallSuccess = () => {
if (showInstalledModal.value) {
refreshInstalledApps();
}
};
// 已安装应用与云端同步候选用 key 集合(供恢复模态判断已装项)
const installedCloudKeys = computed(
() => new Set(installedApps.value.map((app) => cloudItemKey(app))),
);
const installedCloudPackageKeys = computed(
() => new Set(syncCandidateApps.value.map((app) => cloudPackageKey(app))),
);
export {
isInstalledAppInfo,
LIST_INSTALLED_TIMEOUT_MS,
resolveInstalledOrigins,
refreshInstalledApps,
mapInstalledAppToCatalogApp,
refreshFavoriteInstalledApps,
requestUninstall,
openInstalledModal,
closeInstalledModal,
uninstallInstalledApp,
onUninstallSuccess,
installedCloudKeys,
installedCloudPackageKeys,
};