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>
This commit is contained in:
2026-10-11 12:34:10 +08:00
parent 48a6e10263
commit 56a7e11622
16 changed files with 415 additions and 1056 deletions
+8 -1
View File
@@ -202,6 +202,12 @@ const refreshInstalledSyncCandidates = async (
}
const refreshedApps: App[] = [];
// 只记录「查询成功」的来源:list-installed 在来源被启动参数禁用或对应
// 包管理器不可用时会返回 success:false(而非 reject)。若仍把它算作已刷新,
// mergeInstalledApps 会按「该来源已刷新」丢弃本地已知记录,随后
// syncInstalledAppsToAccount 会把这份不完整列表 PUT/me/app-list 覆盖到账号,
// 造成云端应用列表静默丢数据。
const refreshedOrigins: Array<"spark" | "apm"> = [];
await Promise.all(
origins.map(async (origin) => {
const pkgnameList =
@@ -216,6 +222,7 @@ const refreshInstalledSyncCandidates = async (
});
if (!result?.success) return;
refreshedOrigins.push(origin);
const appList = Array.isArray(result?.apps) ? result.apps : [];
for (const rawApp of appList) {
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
@@ -233,7 +240,7 @@ const refreshInstalledSyncCandidates = async (
syncCandidateApps.value = mergeInstalledApps(
syncCandidateApps.value,
refreshedApps,
origins,
refreshedOrigins,
);
return true;
};
+5 -1
View File
@@ -75,6 +75,11 @@ export {
currentApp,
currentAppSparkInstalled,
currentAppApmInstalled,
// APM 安装引导弹窗开关必须与 modules/processInstall 写入的 ref 同源,
// 故同样从 storeConfig 再导出。历史上此处重复 `ref(false)` 声明过一份,
// 导致 processInstall 置位 true 后 App.vue 读到的仍是另一实例,
// APM 未安装时点安装什么都不会发生(弹窗静默不显示)。
showApmInstallDialog,
} from "../global/storeConfig";
export const showModal = ref(false);
export const showPreview = ref(false);
@@ -139,7 +144,6 @@ export const showUninstallModal = ref(false);
export const uninstallTargetApp: Ref<App | null> = ref(null);
export const showAboutModal = ref(false);
export const showSettingsModal = ref(false);
export const showApmInstallDialog = ref(false);
// 可用来源 computed(供 storeFilter 相关判断复用)
export const availableSources = computed(() => ({
+25 -6
View File
@@ -34,12 +34,24 @@ import {
getEffectiveStoreFilter,
} from "../modules/storeFilter";
import { cloudItemKey, cloudPackageKey } from "../modules/appListSync";
import { removeDownloadItem } from "../global/downloadStatus";
// 运行时类型守卫(重构前在 App.vue 中校验完整字段,拆分时被削弱成只查
// pkgname/origin,导致字段残缺的行会带着 undefined 流入已安装列表与云端
// 同步条目)。主进程 list-installed 始终按下方结构回传,逐字段校验即可。
const isInstalledAppInfo = (value: unknown): value is InstalledAppInfo => {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return typeof v.pkgname === "string" && typeof v.origin === "string";
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;
@@ -269,6 +281,7 @@ const refreshFavoriteInstalledApps = async (): Promise<void> => {
}
const refreshedApps: App[] = [];
const refreshedOrigins: Array<"spark" | "apm"> = [];
await Promise.all(
origins.map(async (origin) => {
const pkgnameList =
@@ -283,6 +296,10 @@ const refreshFavoriteInstalledApps = async (): Promise<void> => {
});
if (!result?.success) return;
// 仅「查询成功」的来源才算已刷新:来源禁用/包管理器缺失时返回
// success:false,此时若仍从列表里剔除该来源的已知应用,会让已安装
// 列表凭空少掉一整类应用(与云端同步丢数据同源)。
refreshedOrigins.push(origin);
const appList = Array.isArray(result?.apps) ? result.apps : [];
for (const rawApp of appList) {
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
@@ -299,7 +316,7 @@ const refreshFavoriteInstalledApps = async (): Promise<void> => {
installedApps.value = [
...installedApps.value.filter(
(app) =>
!origins.includes(app.origin) &&
!refreshedOrigins.includes(app.origin) &&
!refreshedKeys.has(`${app.origin}:${app.pkgname}`),
),
...refreshedApps,
@@ -309,7 +326,9 @@ const refreshFavoriteInstalledApps = async (): Promise<void> => {
const requestUninstall = (app: App) => {
uninstallTargetApp.value = app;
showUninstallModal.value = true;
removeDownloadItem(app.pkgname);
// 注意:不要在此处移除下载记录。卸载尚未确认/完成,提前移除会导致
// 用户取消卸载也丢失记录;实际移除由 remove-complete 成功后统一处理
// (见 App.vue handleRemoveComplete),与重构前的语义一致。
};
const openInstalledModal = () => {