From b89c001401e08c066f48493c48b3d86a429e2ab9 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Mon, 3 Aug 2026 11:06:28 +0800 Subject: [PATCH 01/36] =?UTF-8?q?refactor:=20=E5=9F=BA=E4=BA=8E=20upstream?= =?UTF-8?q?/Erotica=20=E9=87=8D=E6=96=B0=E6=95=B4=E5=90=88=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=8A=9F=E8=83=BD=EF=BC=88=E6=9D=A5=E6=BA=90=E9=80=89?= =?UTF-8?q?=E6=8B=A9/=E5=90=88=E5=B9=B6=E8=AF=A6=E6=83=85/=E8=AE=A1?= =?UTF-8?q?=E6=95=B0/=E7=AA=97=E5=8F=A3/=E9=A6=96=E9=A1=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将本地在 upstream !408(已安装应用合并+统计)之上的后续改动重新整合到最新 upstream/Erotica: - 已安装应用:卸载/打开按 APM/Spark 来源选择;计数总数=APM+Spark 包数(双来源计为独立包) - 查看详情:已安装页复用所有应用页合并详情视图(单来源优先已装类型,双来源自动模式) - 窗口:恢复阈值改为 1600x900,过大回退默认尺寸并居中,不自动最大化 - 首页推荐:致谢卡片移至其他内容下方,加大标题/卡片/致谢间距 - 隐藏排行榜/荣耀榜侧栏入口(代码保留,v-if=false) - 新增 scripts/test-build.sh 测试打包脚本(版本末段+1 加 -test)并更新 AGENTS.md 冲突文件(index.ts/App.vue/InstalledAppsModal.vue)均取本地完整实现(为上游版本的超集)。 --- .gitignore | 3 + AGENTS.md | 17 ++ docs/ranking-honor-plan.md | 162 +++++++++++ electron/main/index.ts | 32 ++- scripts/test-build.sh | 77 ++++++ src/App.vue | 327 +++++++++++++++++++---- src/components/AppCard.vue | 61 ++++- src/components/AppDetailModal.vue | 94 ++++++- src/components/AppGrid.vue | 4 +- src/components/AppHeader.vue | 108 -------- src/components/AppSidebar.vue | 23 ++ src/components/DownloadQueue.vue | 34 ++- src/components/HomeView.vue | 162 ++++++----- src/components/HonorView.vue | 148 ++++++++++ src/components/InstalledAppsModal.vue | 143 ++++++++-- src/components/RankingAppRow.vue | 101 +++++++ src/components/RankingContributorRow.vue | 55 ++++ src/components/RankingEmptyState.vue | 18 ++ src/components/RankingView.vue | 145 ++++++++++ src/components/SourceLabel.vue | 24 ++ src/components/ThanksCard.vue | 44 +++ src/components/UninstallConfirmModal.vue | 48 +++- src/components/WindowTitleBar.vue | 128 +++++++-- src/global/typedefinition.ts | 2 + src/modules/ranking.ts | 89 ++++++ 25 files changed, 1750 insertions(+), 299 deletions(-) create mode 100644 docs/ranking-honor-plan.md create mode 100755 scripts/test-build.sh delete mode 100644 src/components/AppHeader.vue create mode 100644 src/components/HonorView.vue create mode 100644 src/components/RankingAppRow.vue create mode 100644 src/components/RankingContributorRow.vue create mode 100644 src/components/RankingEmptyState.vue create mode 100644 src/components/RankingView.vue create mode 100644 src/components/SourceLabel.vue create mode 100644 src/components/ThanksCard.vue create mode 100644 src/modules/ranking.ts diff --git a/.gitignore b/.gitignore index 380dcd59..e478a394 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ debian/files debian/tmp/ debian/.debhelper/ debian/spark-store/ + +# AI working memory (跨会话上下文,不进入上游) +.codebuddy/ diff --git a/AGENTS.md b/AGENTS.md index 48e4cb46..4c317933 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -695,6 +695,23 @@ npm run build:rpm # 仅构建 RPM 包 - `dist/` - 编译的渲染器资源 - 打包的应用在项目根目录 +### 测试打包脚本(开发过程统一使用) + +开发 / 自测阶段的打包**一律使用测试脚本 `scripts/test-build.sh`**,不要直接手敲 `dpkg-buildpackage`: + +```bash +./scripts/test-build.sh +``` + +脚本行为: + +- 自动读取 `debian/changelog` 顶部的 deb 版本号(如 `5.2.1.0`),将**最后一段数字 +1** 并追加 `-test` 修订号(如 `5.2.1.1-test`)。 +- 每次运行版本递增(`5.2.1.1-test` → `5.2.1.2-test` → …),产物形如 `spark-store_5.2.1.X-test_amd64.deb`,与正式发版版本区分。 +- 自动设置 `ELECTRON_MIRROR` 镜像(`https://registry.npmmirror.com/-/binary/electron/`)后执行 `dpkg-buildpackage -us -uc -b`。 +- 仅修改 `debian/changelog`,不改 `package.json`(electron-builder 的 dir 产物版本由 `package.json` 决定,不影响 deb 文件名)。 + +> 注意:每次运行会改写 `debian/changelog`(未跟踪的工作区改动),测试完成后按需自行还原为正式版本。 + ### 构建配置 **electron-builder.yml:** diff --git a/docs/ranking-honor-plan.md b/docs/ranking-honor-plan.md new file mode 100644 index 00000000..9c649175 --- /dev/null +++ b/docs/ranking-honor-plan.md @@ -0,0 +1,162 @@ +# 排行榜与星火荣耀榜 · 项目规划文档 + +> 状态:**草图设计已确认,待实现** +> 分支:`Erotica` +> 日期:2026-07-30 +> 关联预览原型:`ranking-preview.html`(仓库根目录,仅用于设计预览,不入库) + +--- + +## 1. 项目背景与目标 + +在「已安装应用」模块重构(来源标签化、统计徽章、搜索、来源筛选、APM 置顶)完成后,本次规划聚焦于**首页与发现页的内容重组**: + +- 将原本散落在「首页推荐」中的**下载排行**独立为「排行榜」一级页面,并补充**应用更新排行**; +- 新增**「星火荣耀榜」**页面,表彰应用贡献者与近期更新贡献者; +- 精简「首页推荐」页:移除精选板块(区域2)与下载排行(区域3),仅保留精选链接(区域1),并在区域1 顶部加入致谢说明。 + +**预期成果**:形成「首页推荐(轻量入口)— 排行榜(下载/更新)— 荣耀榜(贡献者荣誉)」三层递进的发现体系,强化社区贡献者认同感。 + +--- + +## 2. 数据可行性核查(代码已确认) + +| 字段 | 来源 | 结论 | +|------|------|------| +| `app.downloadCount` | 现有 `apmRanking` / `sparkRanking`(`App.vue` 1164–1250,异步逐应用拉 `download-times.txt`) | ✅ 直接复用,无需新增请求 | +| `app.update` | `App.vue:886` 由 `appJson.Update` 映射,格式 `"2026-01-26 17:34:15"`,同格式可直接 `localeCompare` 倒序 | ✅ 列表已含,可直接排序 | +| `app.contributor` | `App.vue:884` 由 `appJson.Contributor` 映射 | ✅ 列表已含,无需详情请求 | + +> 结论:**荣耀榜/更新榜所需字段均已在列表加载阶段填充**,聚合逻辑可基于已加载的 `apps` 实时计算,不引入额外网络请求。 + +--- + +## 3. 任务总览(优先级 + 阶段) + +| 任务ID | 名称 | 类型 | 优先级 | 阶段 | 关联模块 | +|--------|------|------|--------|------|----------| +| F1 | 排行榜页(RankingView) | 新功能 | P0 | 待实现 | App.vue, AppSidebar, HomeView(移除区域3) | +| F1.1 | 应用下载排行(Spark/APM 双榜) | 新功能 | P0 | 待实现 | 复用 apmRanking/sparkRanking | +| F1.2 | 应用更新排行(Spark/APM 双榜) | 新功能 | P0 | 待实现 | apps(update 字段) | +| F2 | 星火荣耀榜页(HonorView) | 新功能 | P0 | 待实现 | App.vue, AppSidebar | +| F2.1 | 贡献荣耀榜(按来源聚合 contributor) | 新功能 | P0 | 待实现 | apps(contributor 字段) | +| F2.2 | 更新荣耀榜(最近更新应用 contributor 聚合) | 新功能 | P0 | 待实现 | apps(update+contributor) | +| F2.3 | 致谢说明卡片 | 新功能 | P1 | 待实现 | HonorView / HomeView 复用 | +| F3 | 侧边栏导航入口 | 新功能 | P0 | 待实现 | AppSidebar.vue, App.vue | +| M1 | 首页推荐页改造(去区域2/3,区域1加致谢) | 修改现有 | P0 | 待实现 | HomeView.vue | +| M2 | App.vue 视图路由与数据编排 | 修改现有 | P0 | 待实现 | App.vue | +| M3 | 榜单聚合工具函数 | 重构/提取 | P1 | 待优化 | 新增 util 或 App.vue 内 computed | +| O1 | 边加载边更新体验优化 | 优化 | P2 | 待优化 | HonorView(computed + watch) | +| O2 | 榜单条数/来源可配置 | 优化 | P2 | 待优化 | RankingView/HonorView props | +| O3 | 空状态与加载失败兜底 | 优化 | P1 | 待优化 | RankingView/HonorView | +| R1 | 类型安全与 lint 修复 | 修复 | P1 | 待修复 | 全部新增组件 | +| R2 | 与现有列表加载/更新逻辑的一致性校验 | 修复 | P1 | 待修复 | App.vue, install-manager | + +--- + +## 4. 详细任务分解 + +### 4.1 新功能模块 + +#### F1 · 排行榜页(RankingView.vue) + +**F1.1 应用下载排行** +- **改动描述**:新增 `RankingView` 组件,区块一展示「应用下载排行」,内部按来源分为 Spark 下载榜 / APM 下载榜两栏,各取下载量前 10 名。复用现有 `apmRanking` / `sparkRanking` 数据(由 `App.vue` 传入),列表项沿用 `AppCard` 的 `compact` 样式,并在左侧加排名序号(前 3 名用金/银/铜配色)。 +- **预期目标**:与现有首页下载排行视觉一致,且独立成页后信息更聚焦。 +- **关联模块**:`App.vue`(传入 `apmRanking`/`sparkRanking`)、`AppCard.vue`、`AppSidebar.vue`(入口)。 +- **备注**:当前首页区域3 的展示可直接迁移,避免重复实现。 + +**F1.2 应用更新排行** +- **改动描述**:区块二展示「应用更新排行」,按来源分为 Spark 更新榜 / APM 更新榜两栏,各取 `app.update` 倒序前 10 名;列表项复用 `AppCard` compact 样式,副信息显示版本与更新时间(替换下载量徽章)。 +- **预期目标**:让用户快速了解近期活跃更新的应用。 +- **关联模块**:`App.vue`(`apps` 全量数据)、`AppCard.vue`。 +- **备注**:排序用 `update.localeCompare` 倒序(格式统一可行)。 + +#### F2 · 星火荣耀榜页(HonorView.vue) + +**F2.1 贡献荣耀榜** +- **改动描述**:区块一展示「贡献荣耀榜」,按来源分为 Spark / APM 两栏,各取 `app.contributor` 出现次数前 10 名(聚合基于已加载 `apps`,实时计算)。列表项显示排名、贡献者名(去除 `` 后缀)、上榜应用数徽章。 +- **预期目标**:表彰对星火生态贡献最多的开发者/维护者。 +- **关联模块**:`App.vue`(`apps`)、`HonorView.vue`。 + +**F2.2 更新荣耀榜** +- **改动描述**:区块二展示「更新荣耀榜」,按来源分为 Spark / APM 两栏;取最近更新(按 `update` 倒序)的前 30 个应用,聚合其 `contributor` 出现次数,取前 10 名展示。 +- **预期目标**:突出近期持续维护应用的贡献者。 +- **关联模块**:`App.vue`(`apps`)、`HonorView.vue`。 +- **备注**:与 F1.2 同源(最近更新应用列表),可共享计算。 + +**F2.3 致谢说明卡片** +- **改动描述**:在荣耀榜页顶部加入美化后的致谢卡片(渐变琥珀背景 + 图标 + 文案「致每一位星火贡献者…🎉」);同一卡片样式也用于首页推荐区域1(见 M1)。 +- **预期目标**:强化社区归属感,视觉与整体风格统一。 +- **关联模块**:`HonorView.vue`、`HomeView.vue`(复用同一段模板/组件)。 + +#### F3 · 侧边栏导航入口 + +- **改动描述**:在 `AppSidebar.vue` 新增「排行榜」「荣耀榜」两个一级入口,点击分别 `selectTab('ranking')` / `selectTab('honor')`,激活态沿用 `.sidebar-tab-active` 样式。 +- **预期目标**:用户可从侧边栏直达新页面。 +- **关联模块**:`AppSidebar.vue`、`App.vue`(`activeTab` 状态)。 + +### 4.2 需要修改的现有模块 + +#### M1 · 首页推荐页改造(HomeView.vue) + +- **改动描述**:移除模板中的**区域2·精选板块**(`recommendSections` 应用列表区块)与**区域3·下载排行**(`apmRanking`/`sparkRanking` 区块)及其对应 props(`recommendSections`、`apmRanking`、`sparkRanking`、`rankingLoading`);在区域1(精选链接 `homelinks` 网格)顶部插入致谢说明卡片(F2.3 同款)。 +- **预期目标**:首页回归轻量入口定位,下载排行/精选板块职责移交排行榜页与「全部应用」。 +- **关联模块**:`HomeView.vue`、`App.vue`(停止向 HomeView 传 ranking/recommend 数据,或保留 recommend 供「全部应用」使用——需确认 recommendSections 是否仍被其他入口复用)。 +- **备注**:⚠️ 需确认 `recommendSections` 是否仅首页使用;若「全部应用」等也依赖,则不能简单删除数据加载。 + +#### M2 · App.vue 视图路由与数据编排 + +- **改动描述**:主内容区新增 `v-else-if="activeTab === 'ranking'"` → ``、`v-else-if="activeTab === 'honor'"` → ``;将现有 `apmRanking`/`sparkRanking` 改传 `RankingView`;荣耀榜所需的全量 `apps` 以 `computed` 形式实时传入 `HonorView`(支撑"边加载边更新")。 +- **预期目标**:打通新页面路由与数据流。 +- **关联模块**:`App.vue`、`RankingView.vue`、`HonorView.vue`。 + +#### M3 · 榜单聚合工具函数(待优化阶段) + +- **改动描述**:将"按来源取 Top N""聚合 contributor 次数"等逻辑提取为独立 `util` 函数(如 `src/modules/ranking.ts`),供 RankingView/HonorView 复用,避免组件中重复实现。 +- **预期目标**:提升可维护性、便于单元测试。 +- **关联模块**:新增 `src/modules/ranking.ts`。 + +--- + +## 5. 阶段划分与执行顺序 + +### 阶段一 · 待实现(P0,核心交付) +1. **M2** App.vue 路由骨架 + **F3** 侧边栏入口(打通页面切换) +2. **F1** RankingView(F1.1 下载榜 → F1.2 更新榜) +3. **F2** HonorView(F2.1 贡献榜 → F2.2 更新榜 → F2.3 致谢) +4. **M1** HomeView 首页改造(去区域2/3 + 区域1 致谢) + +### 阶段二 · 待优化(P1–P2,体验打磨) +- **O3** 空状态/加载失败兜底(榜单为空、apps 未加载时友好提示) +- **M3 / O2** 聚合工具提取、榜单条数可配置(当前固定 10) +- **O1** 边加载边更新:用 `computed` + `watch(apps)` 实时刷新,进入荣耀榜即开始聚合,无需手动定时器 + +### 阶段三 · 待修复(P1,质量保障) +- **R1** 全部新增组件通过 `vue-tsc` 类型检查与 ESLint(严格模式,禁用 `any`) +- **R2** 校验与现有列表加载(`loadCategories`/`refreshInstalledApps`)、更新逻辑的一致性,避免重复遍历导致的性能问题(全量 `apps` 聚合建议在 `computed` 中记忆化) + +--- + +## 6. 风险与待确认项 + +1. **推荐数据归属**:`recommendSections` 是否仅首页使用?若「全部应用」等入口复用,M1 删除区域2 时需保留数据加载逻辑,仅移除模板渲染。 +2. **荣耀榜数据范围**:确认基于"已加载 `apps`"即可满足需求(用户已确认"只基于已加载应用"),无需分页/全量后端接口。 +3. **榜单条数**:当前统一为 10 条(用户确认),后续是否需要在 UI 上可切换(O2)。 +4. **贡献者字段格式**:`contributor` 形如 `name`,聚合与展示时需统一去除 `<...>` 取显示名;多个贡献者是否以 `;` 分隔需抽样确认。 +5. **storeFilter 影响**:下载榜已按 Spark/APM 分榜;更新榜/荣耀榜按来源分布已实现。若 `storeFilter==='spark'` 仅显示 Spark 榜,需在主内容区做来源过滤。 + +--- + +## 7. 验收标准 + +- [ ] 侧边栏出现「排行榜」「荣耀榜」入口,点击正确切换页面 +- [ ] 排行榜页:下载榜(Spark/APM)与更新榜(Spark/APM)各显示 Top 10,视觉与现有 AppCard 一致 +- [ ] 荣耀榜页:贡献荣耀榜、更新荣耀榜各按来源 Top 10,且随 `apps` 加载实时更新;顶部致谢卡片正确显示 +- [ ] 首页推荐:仅保留区域1(精选链接)+ 致谢卡片,区域2/3 已移除 +- [ ] 全部新增/修改通过 `npm run lint` 与 `vue-tsc` 类型检查 +- [ ] 打包(`dpkg-buildpackage`)成功,功能在 GUI 环境自测通过 + +--- + +*本文档基于 `ranking-preview.html` 设计原型与代码核查结论编写,作为后续开发与迭代的执行依据。* diff --git a/electron/main/index.ts b/electron/main/index.ts index 7911d5d7..6d6e1dc5 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -289,6 +289,8 @@ const showAndFocusMainWindow = (): void => { // 窗口尺寸持久化:保存/恢复上一次调整后的窗口大小,避免每次打开都使用默认尺寸 const DEFAULT_WINDOW_SIZE = { width: 1366, height: 768 }; const MIN_WINDOW_SIZE = { width: 800, height: 500 }; +// 超过该尺寸的窗口(通常为全屏/最大化状态)在恢复时回退到默认尺寸,避免「启动即全屏、还原按钮失效」 +const OVERSIZED_WINDOW_THRESHOLD = { width: 1600, height: 900 }; interface WindowState { width?: number; @@ -375,12 +377,24 @@ function scheduleSaveBounds(winInstance: BrowserWindow): void { async function createWindow() { const saved = loadWindowState(); + // 恢复时:若上次窗口过大(>1600x900,通常为全屏/最大化),回退到默认尺寸并居中, + // 避免「启动即全屏、还原按钮失效」;其余情况保留上次记录的实际尺寸(并居中)。 + // 放大/还原仍交由标题栏按钮控制。 + const oversized = + (saved.width ?? 0) > OVERSIZED_WINDOW_THRESHOLD.width || + (saved.height ?? 0) > OVERSIZED_WINDOW_THRESHOLD.height; + const restoredWidth = oversized + ? DEFAULT_WINDOW_SIZE.width + : Math.max(saved.width ?? DEFAULT_WINDOW_SIZE.width, MIN_WINDOW_SIZE.width); + const restoredHeight = oversized + ? DEFAULT_WINDOW_SIZE.height + : Math.max(saved.height ?? DEFAULT_WINDOW_SIZE.height, MIN_WINDOW_SIZE.height); + const mainWindow = new BrowserWindow({ title: "星火应用商店", - width: saved.width ?? DEFAULT_WINDOW_SIZE.width, - height: saved.height ?? DEFAULT_WINDOW_SIZE.height, - x: saved.x, - y: saved.y, + width: restoredWidth, + height: restoredHeight, + center: true, minWidth: MIN_WINDOW_SIZE.width, minHeight: MIN_WINDOW_SIZE.height, frame: false, @@ -398,11 +412,11 @@ async function createWindow() { }); win = mainWindow; - // 恢复上一次的最大化状态 - if (saved.maximized) { - mainWindow.maximize(); - } - logger.info({ saved }, "已恢复窗口状态"); + // 不再自动恢复最大化状态(避免无法还原);启动即居中显示,过大窗口已回退默认尺寸 + logger.info( + { saved, restoredWidth, restoredHeight, oversized }, + "已恢复窗口状态(过大窗口回退默认尺寸并居中)", + ); // 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复 // 位置/最大化变化由主进程事件保存;尺寸变化由渲染端 DOM resize 经 IPC 兜底保存 diff --git a/scripts/test-build.sh b/scripts/test-build.sh new file mode 100755 index 00000000..8346bafe --- /dev/null +++ b/scripts/test-build.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# 测试打包脚本 +# +# 用途:本地测试打包时,自动将 deb 版本号(位于 debian/changelog 顶部)的 +# 最后一段数字 +1,并追加 "-test" 标签,随后执行 dpkg-buildpackage。 +# +# 示例: +# 当前 debian/changelog 顶部版本为 5.2.1.0 +# 运行一次 -> 5.2.1.1-test(产物 spark-store_5.2.1.1-test_amd64.deb) +# 再运行一次 -> 5.2.1.2-test +# +# 注意: +# - 此脚本仅用于本地测试,不会修改 package.json(electron-builder 的 dir 产物版本)。 +# - 每次运行都会改写 debian/changelog,属于未跟踪的工作区改动,按需自行还原。 +# - 如需恢复正式版本,将 debian/changelog 顶部版本改回即可。 + +set -euo pipefail + +# 定位仓库根目录(脚本位于 scripts/ 下) +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +CHANGELOG="debian/changelog" + +if [ ! -f "$CHANGELOG" ]; then + echo "错误:未找到 $CHANGELOG" >&2 + exit 1 +fi + +# 读取 changelog 首行并解析版本号,形如: +# spark-store (5.2.1.0) UNRELEASED; urgency=medium +# spark-store (5.2.1.1-test) UNRELEASED; urgency=medium +top_line="$(head -n1 "$CHANGELOG")" +# 提取括号内的版本号,避免使用 [[ =~ ]] 处理字面括号引发的语法问题 +old_ver="$(printf '%s' "$top_line" | sed -E 's/^[^ ]+ \(([^)]+)\).*/\1/')" +if [[ -z "$old_ver" || "$old_ver" == "$top_line" ]]; then + echo "错误:无法从 $CHANGELOG 首行解析版本号: $top_line" >&2 + exit 1 +fi + +# 分离上游版本与 Debian 修订号: +# Debian 版本格式为 "上游版本-修订号",修订号以最后一个 '-' 分隔。 +# 上游版本只允许点号(如 5.2.1.0),'-test' 这类标签属于修订号,每次测试覆盖为固定 "test"。 +if [[ "$old_ver" == *-* ]]; then + upstream="${old_ver%-*}" +else + upstream="$old_ver" +fi + +# 上游版本形如 X.Y.Z.W,将最后一个点号分隔的数字段 +1 +if [[ "$upstream" =~ ^(.*)\.([0-9]+)$ ]]; then + prefix="${BASH_REMATCH[1]}" + last="${BASH_REMATCH[2]}" + # 10# 强制按十进制解析,避免以 0 开头的段被当作八进制 + new_last=$((10#$last + 1)) + new_upstream="${prefix}.${new_last}" +else + echo "错误:上游版本号不符合 X.Y.Z.W 格式: $upstream" >&2 + exit 1 +fi + +new_ver="${new_upstream}-test" + +echo "版本号: ${old_ver} -> ${new_ver}" + +# 仅替换首行括号内的版本号(转义点号,避免被正则当作任意字符) +old_ver_esc="${old_ver//./\\.}" +sed -i -E "1s/\\(${old_ver_esc}\\)/(${new_ver})/" "$CHANGELOG" + +# Electron 二进制下载镜像(本机到 GitHub 不通,使用 npmmirror 镜像) +export ELECTRON_MIRROR="${ELECTRON_MIRROR:-https://registry.npmmirror.com/-/binary/electron/}" + +echo "开始测试打包(版本 ${new_ver})..." +dpkg-buildpackage -us -uc -b + +echo "完成。产物: ../spark-store_${new_ver}_amd64.deb" diff --git a/src/App.vue b/src/App.vue index f8b1a4ef..f37712a6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,7 +4,15 @@ v-else class="flex h-screen flex-col overflow-hidden bg-slate-50 text-slate-900 transition-colors duration-300 dark:bg-slate-950 dark:text-slate-100" > - +
@@ -46,21 +54,6 @@
-
- -
+ + @@ -181,7 +197,7 @@ @close="closeInstalledModal" @refresh="refreshInstalledApps" @open-app="openDownloadedApp($event.pkgname, $event.origin)" - @open-detail="openDetail" + @open-detail="openDetailFromInstalled" @uninstall="uninstallInstalledApp" @sync-to-account="syncInstalledAppsToAccount" @restore-from-account="openRestoreFromAccount" @@ -288,9 +304,10 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue"; import axios, { AxiosError } from "axios"; import pino from "pino"; import AppSidebar from "./components/AppSidebar.vue"; -import AppHeader from "./components/AppHeader.vue"; import AppGrid from "./components/AppGrid.vue"; import HomeView from "./components/HomeView.vue"; +import RankingView from "./components/RankingView.vue"; +import HonorView from "./components/HonorView.vue"; import CategoryBar from "./components/CategoryBar.vue"; import AppDetailModal from "./components/AppDetailModal.vue"; import ScreenPreview from "./components/ScreenPreview.vue"; @@ -904,6 +921,11 @@ const fetchAppFromStore = async ( } }; +// 已安装应用页查看详情:复用所有应用页的合并详情视图(始终展示 APM/Spark 两种包) +const openDetailFromInstalled = (app: App) => { + openDetail({ ...app, _fromInstalled: true }); +}; + const openDetail = async (app: App | Record) => { // 提取 pkgname 和 category(必须存在) const pkgname = (app as Record).pkgname as string; @@ -912,17 +934,36 @@ const openDetail = async (app: App | Record) => { // 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息) const fromHomeView = (app as Record)._fromHomeView === true; const fromDeepLink = (app as Record)._fromDeepLink === true; - const needFetchFromStore = fromHomeView || fromDeepLink; + // 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型) + const fromInstalled = (app as Record)._fromInstalled === true; + const needFetchFromStore = fromHomeView || fromDeepLink || fromInstalled; if (!pkgname) { console.warn("openDetail: 缺少 pkgname", app); return; } // 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找 - let fullApp = filteredApps.value.find((a) => a.pkgname === pkgname); - // 如果没找到,回退到全局 apps 中查找 + // 优先匹配点击来源 origin(例如排行点击 APM 应用时不应误匹配到 Spark 版) + const clickedOrigin = (app as Record).origin as + | "spark" + | "apm" + | undefined; + let fullApp = filteredApps.value.find( + (a) => + a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin), + ); + // 如果没找到,回退到全局 apps 中查找(同样优先 origin) if (!fullApp) { - fullApp = apps.value.find((a) => a.pkgname === pkgname); + fullApp = apps.value.find( + (a) => + a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin), + ); + } + // 仍无匹配则退化为仅按 pkgname 匹配(兼容无 origin 的场景,如搜索结果) + if (!fullApp) { + fullApp = + filteredApps.value.find((a) => a.pkgname === pkgname) || + apps.value.find((a) => a.pkgname === pkgname); } let finalApp: App; @@ -930,27 +971,38 @@ const openDetail = async (app: App | Record) => { // 来自 HomeView 或 DeepLink 的应用需要重新从仓库获取完整信息 if (needFetchFromStore) { // 从 Spark 和 APM 仓库获取完整的应用信息 - const [sparkApp, apmApp] = await Promise.all([ - storeFilter.value !== "apm" + // 已安装页忽略当前商店单一模式限制,始终尝试拉取两种来源,保证另一类型不被隐藏 + let [sparkApp, apmApp] = await Promise.all([ + fromInstalled || storeFilter.value !== "apm" ? fetchAppFromStore(pkgname, category, "spark") : Promise.resolve(null), - storeFilter.value !== "spark" + fromInstalled || storeFilter.value !== "spark" ? fetchAppFromStore(pkgname, category, "apm") : Promise.resolve(null), ]); + // 已安装页:若某来源仓库拉取失败(如分类不匹配),用本地已合并的应用补全,避免丢失另一类型 + if (fromInstalled && fullApp && fullApp.isMerged) { + const merged = fullApp as App; + if (!sparkApp && merged.sparkApp) sparkApp = merged.sparkApp; + if (!apmApp && merged.apmApp) apmApp = merged.apmApp; + } + // 构建合并的应用对象 if (sparkApp || apmApp) { // 如果两个仓库都有这个应用,创建合并对象 if (sparkApp && apmApp) { - // 根据优先级配置决定默认显示哪个版本 - const defaultOrigin = getHybridDefaultOrigin(sparkApp); + // 优先遵从点击来源 origin;无点击来源时再按优先级配置决定默认显示 + const defaultOrigin = + clickedOrigin && (clickedOrigin === "spark" ? sparkApp : apmApp) + ? clickedOrigin + : getHybridDefaultOrigin(sparkApp); finalApp = { ...(defaultOrigin === "spark" ? sparkApp : apmApp), // 根据优先级选择主显示 isMerged: true, sparkApp: sparkApp, apmApp: apmApp, - viewingOrigin: defaultOrigin, // 默认查看优先级高的版本 + viewingOrigin: defaultOrigin, // 默认查看来源版本 }; } else if (sparkApp) { finalApp = sparkApp; @@ -1027,12 +1079,38 @@ const openDetail = async (app: App | Record) => { }) as Promise) : Promise.resolve(false), ]); - if (sparkInstalled && !apmInstalled) { + // 优先遵从点击来源 origin(未安装时、已安装时都应以用户点击的版本为准) + let preferred = (app as Record).origin as + | "spark" + | "apm" + | undefined; + if (fromInstalled) { + // 已安装页:依据实际安装来源决定默认展示 + const installedOrigins = (app as Record).origins as + | Array<"spark" | "apm"> + | undefined; + if (installedOrigins && installedOrigins.length > 0) { + if ( + installedOrigins.includes("spark") && + installedOrigins.includes("apm") + ) { + // 两种类型都已安装:保持所有应用页的自动显示模式(不强制指定来源) + preferred = undefined; + } else { + // 仅安装一种类型:优先显示已安装的类型(另一类型仍可在来源切换中查看) + preferred = installedOrigins[0]; + } + } + } + if (preferred && (preferred === "spark" ? finalApp.sparkApp : finalApp.apmApp)) { + finalApp.viewingOrigin = preferred; + } else if (sparkInstalled && !apmInstalled) { + // 无点击来源时:仅一个仓库已安装则优先展示已安装版本 finalApp.viewingOrigin = "spark"; } else if (apmInstalled && !sparkInstalled) { finalApp.viewingOrigin = "apm"; } else { - // 若都安装或都未安装,根据优先级配置决定默认展示 + // 都安装/都未安装且未指定来源:按优先级配置决定默认展示 finalApp.viewingOrigin = getHybridDefaultOrigin( finalApp.sparkApp || finalApp, ); @@ -1134,6 +1212,125 @@ const homeLinks = ref([]); const homeLoading = ref(false); const homeError = ref(""); +// 首页三区域:区域2 板块(已迁移至「全部应用」等分类入口,首页不再展示) +const apmRanking = ref([]); +const sparkRanking = ref([]); + +// 区域3 · 下载排行(全站):从全量应用目录逐应用拉取 download-times.txt, +// 按 origin 分别排 APM / Spark 两榜。用代次守卫避免竞态。 +// 优化:localStorage 缓存(1 小时 TTL)避免重复请求;并发 48;每个批次完成后立即发布 +// 增量排名,边拉边显;fetchDownloadCount 优先命中缓存。 +let rankingGeneration = 0; +const rankingLoading = ref(false); + +const DOWNLOAD_COUNT_CACHE_KEY = "spark-store:download-counts:v1"; +const DOWNLOAD_COUNT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 小时 + +interface CachedCount { + count: number; + ts: number; +} + +const downloadCountCache = new Map(); + +const cacheKey = (app: App) => `${app.origin}:${app.category}:${app.pkgname}`; + +const loadCacheFromStorage = () => { + try { + const raw = localStorage.getItem(DOWNLOAD_COUNT_CACHE_KEY); + if (!raw) return; + const data = JSON.parse(raw) as Record; + const now = Date.now(); + for (const [k, v] of Object.entries(data)) { + if (now - v.ts < DOWNLOAD_COUNT_CACHE_TTL_MS) { + downloadCountCache.set(k, v); + } + } + } catch { + // ignore corrupted cache + } +}; + +const saveCacheToStorage = () => { + try { + const obj: Record = {}; + downloadCountCache.forEach((v, k) => { + obj[k] = v; + }); + localStorage.setItem(DOWNLOAD_COUNT_CACHE_KEY, JSON.stringify(obj)); + } catch { + // ignore quota errors + } +}; + +const fetchDownloadCount = async (app: App): Promise => { + const key = cacheKey(app); + const cached = downloadCountCache.get(key); + if (cached) return cached.count; + const arch = window.apm_store.arch || "amd64"; + const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`; + try { + const resp = await fetch( + `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/download-times.txt`, + ); + if (!resp.ok) return 0; + const text = (await resp.text()).trim(); + const n = parseInt(text, 10); + const count = Number.isFinite(n) ? n : 0; + downloadCountCache.set(key, { count, ts: Date.now() }); + return count; + } catch { + return 0; + } +}; + +const publishRanking = (results: App[]) => { + apmRanking.value = results + .filter((a) => a.origin === "apm") + .sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0)) + .slice(0, 10); + sparkRanking.value = results + .filter((a) => a.origin === "spark") + .sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0)) + .slice(0, 10); +}; + +const loadRanking = async () => { + if (apps.value.length === 0) return; + const gen = ++rankingGeneration; + rankingLoading.value = true; + const all = apps.value.slice(); + const CONCURRENCY = 64; + const results: App[] = []; + for (let i = 0; i < all.length; i += CONCURRENCY) { + if (gen !== rankingGeneration) { + saveCacheToStorage(); + return; + } + const batch = all.slice(i, i + CONCURRENCY); + const settled = await Promise.all( + batch.map(async (app) => ({ + ...app, + downloadCount: await fetchDownloadCount(app), + })), + ); + results.push(...settled); + // 边拉边发:每批完成后立即发布增量排名 + if (gen === rankingGeneration) publishRanking(results); + } + if (gen !== rankingGeneration) { + saveCacheToStorage(); + return; + } + rankingLoading.value = false; + saveCacheToStorage(); +}; + +// 启动时即加载本地缓存(无需等待 apps),二次启动首屏即可见缓存排行 +loadCacheFromStorage(); + +// 排行榜在 loadApps 全量完成后(onMounted)触发一次,确保 spark/apm 应用均已就绪 + const loadHome = async () => { homeLoading.value = true; homeError.value = ""; @@ -1332,7 +1529,22 @@ const loadHomeListApps = async (entryId: string) => { logger.info(`首页列表 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`); }; -// 并行预加载所有侧边栏入口的应用数据,避免点击时才加载导致缓慢 +// 仅并行预加载首页 homeList 板块入口(区域2 数据来源,不依赖全量应用) +const preloadHomeListApps = (): Promise => { + const tasks: Promise[] = []; + for (const entry of sidebarEntries.value) { + if (entry.type === "homeList") { + tasks.push( + loadHomeListApps(entry.id).catch((e: unknown) => + logger.warn(`预加载首页列表 ${entry.id} 失败: ${e}`), + ), + ); + } + } + return Promise.all(tasks).then(() => undefined); +}; + +// 并行预加载其余分类侧边栏入口(用户点击分类时才需要,可延后) const preloadSidebarTabApps = (): Promise => { const tasks: Promise[] = []; for (const entry of sidebarEntries.value) { @@ -1342,12 +1554,6 @@ const preloadSidebarTabApps = (): Promise => { logger.warn(`预加载入口 ${entry.id} 失败: ${e}`), ), ); - } else if (entry.type === "homeList") { - tasks.push( - loadHomeListApps(entry.id).catch((e: unknown) => - logger.warn(`预加载首页列表 ${entry.id} 失败: ${e}`), - ), - ); } } return Promise.all(tasks).then(() => undefined); @@ -1421,8 +1627,15 @@ const handleStartSelectedUpdates = async () => { }; const confirmMigrationStart = async () => { + // 确认即关闭对话框(UX:确认动作立即生效),再执行异步迁移启动 updateCenterStore.showMigrationConfirm.value = false; - await updateCenterStore.startSelected(); + try { + await updateCenterStore.startSelected(); + } catch (error) { + logger.error( + `启动迁移失败 (startSelected): ${error instanceof Error ? error.message : String(error)}`, + ); + } }; const openInstalledModal = () => { @@ -1506,6 +1719,8 @@ const refreshInstalledApps = async () => { if (!showInstalledModal.value) return; const combinedApps: App[] = []; + // 同一 pkgname 可能同时以 APM 与 Spark 两种来源安装,这里聚合其来源集合 + const originsByPkg = new Map>(); const failedOrigins: string[] = []; for (let i = 0; i < effectiveOrigins.length; i++) { @@ -1564,10 +1779,27 @@ const refreshInstalledApps = async () => { isDependency: app.isDependency, }; } - combinedApps.push(appInfo); + // 合并同一 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 @@ -2977,18 +3209,19 @@ onMounted(async () => { loading.value = true; homeLoading.value = true; - // 先加载全部应用,再预加载侧边栏入口 + // 区域1(links) 与 区域2 板块(homeList) 并行;全量应用也并行加载 await Promise.all([ loadHome(), - new Promise((resolve) => { - loadApps(() => { - loading.value = false; - resolve(); - }); + preloadHomeListApps(), + loadApps(() => { + loading.value = false; // 首屏:已有部分应用即可显示,排行稍后刷新 }), ]); - // 全部应用加载完成后再预加载侧边栏入口 + // 全量应用加载完成后再刷新排行榜,确保 spark/apm 应用均已就绪 + loadRanking(); + + // 其余分类入口预加载(用户点击分类时才需要,延后) preloadSidebarTabApps().then(() => { logger.info("侧边栏入口预加载完成"); }); diff --git a/src/components/AppCard.vue b/src/components/AppCard.vue index e266ea8d..82de7541 100644 --- a/src/components/AppCard.vue +++ b/src/components/AppCard.vue @@ -1,10 +1,24 @@
+ {{ formatDownloads(downloadCount) }} -
+
{{ app.pkgname || "" }} · {{ app.version || "" }}
{{ description || "\u00A0" }}
@@ -65,6 +89,12 @@ const props = defineProps<{ app: App; // 是否显示来源标识(仅在混合模式下显示) showOrigin?: boolean; + // 紧凑模式(用于首页精选板块等空间受限区域) + compact?: boolean; + // 下载量(用于下载排行;>=0 时在标题行右侧显示) + downloadCount?: number; + // 排名(用于排行榜/荣耀榜,左上角显示徽标) + rank?: number; }>(); const emit = defineEmits<{ @@ -111,6 +141,27 @@ const description = computed(() => { return more.substring(0, 80) + (more.length > 80 ? "..." : ""); }); +// 下载量格式化:>=1万 显示 "x.x万" +const formatDownloads = (n?: number): string => { + if (!n || n <= 0) return "0"; + if (n >= 10000) { + const wan = n / 10000; + return `${wan.toFixed(1).replace(/\.0$/, "")}万`; + } + return n.toLocaleString(); +}; + +// 排名徽标配色(前 3 名金/银/铜,其余灰色) +const rankClass = computed(() => { + if (props.rank === 1) + return "bg-amber-400 text-white shadow-amber-400/40"; + if (props.rank === 2) + return "bg-slate-300 text-slate-700 shadow-slate-400/40"; + if (props.rank === 3) + return "bg-amber-600 text-white shadow-amber-600/40"; + return "bg-slate-500/90 text-white shadow-slate-500/30"; +}); + const openDetail = () => { emit("open-detail", props.app); }; diff --git a/src/components/AppDetailModal.vue b/src/components/AppDetailModal.vue index 3ef111c2..27c65ea2 100644 --- a/src/components/AppDetailModal.vue +++ b/src/components/AppDetailModal.vue @@ -161,13 +161,7 @@
+ + + + +
+
+
+
+ +
+
+

+ 打开应用 +

+

+ 该应用同时通过 APM 与 Spark 安装,请选择要打开的来源: +

+
+
+
+ + +
+
+ +
+
+
+
@@ -730,6 +788,28 @@ const handleRemove = () => { } }; +// 双来源安装时,"打开"需先让用户选择打开 APM 还是 Spark 版 +const openChoiceVisible = ref(false); +const openOrigins = (app: App | null): Array<"spark" | "apm"> => + app?.origins && app.origins.length > 0 ? app.origins : [app?.origin ?? "spark"]; + +const onOpenClick = () => { + const app = displayApp.value; + if (!app) return; + const origins = openOrigins(app); + if (origins.length > 1) { + openChoiceVisible.value = true; + } else { + emit("open-app", app.pkgname, origins[0]); + } +}; + +const confirmOpen = (origin: "spark" | "apm") => { + const app = displayApp.value; + if (app) emit("open-app", app.pkgname, origin); + openChoiceVisible.value = false; +}; + // 收藏功能暂时关闭 // const handleFavorite = () => { // if (!displayApp.value) return; diff --git a/src/components/AppGrid.vue b/src/components/AppGrid.vue index a2dcefcb..7b1ac177 100644 --- a/src/components/AppGrid.vue +++ b/src/components/AppGrid.vue @@ -19,7 +19,9 @@
-
+
-
-
- -
-
- - - - -
- - -
-
-
- - - diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index a6820f41..d18270da 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -71,6 +71,29 @@ > + + + + +
+ + + +
-
- -

暂无下载任务

-
-
+
diff --git a/src/components/HomeView.vue b/src/components/HomeView.vue index f850a6ee..a1438141 100644 --- a/src/components/HomeView.vue +++ b/src/components/HomeView.vue @@ -1,9 +1,15 @@
- +// 下载量格式化:>= 1万 显示为 "x.x万" +// (download count now formatted inside AppCard via its downloadCount prop) + diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index dba750dc..6d87aba1 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -115,6 +115,8 @@ export interface App { sparkApp?: App; // Optional reference to the spark version apmApp?: App; // Optional reference to the apm version viewingOrigin?: "spark" | "apm"; // Currently viewed origin inside the app modal + origins?: Array<"spark" | "apm">; // 实际安装来源集合(同时以 APM 与 Spark 安装时含两项) + downloadCount?: number; // 下载量(用于下载排行,由 download-times.txt 解析) } export interface UpdateAppItem { diff --git a/src/modules/ranking.ts b/src/modules/ranking.ts new file mode 100644 index 00000000..0b8cb658 --- /dev/null +++ b/src/modules/ranking.ts @@ -0,0 +1,89 @@ +/** + * 排行榜与荣耀榜聚合工具 + * - 下载排行:由 App.vue 预计算好的 apmRanking / sparkRanking 直接传入视图,本模块不重复拉取。 + * - 更新排行 / 贡献荣耀榜 / 更新荣耀榜:基于已加载的 `apps` 实时聚合,无额外网络请求。 + */ +import type { App } from "../global/typedefinition"; + +/** 榜单统一条数(需求确认:10 条) */ +export const TOP_N = 10; + +/** 更新荣耀榜取「最近更新」应用的窗口大小(用于聚合这部分贡献者) */ +export const RECENT_UPDATE_WINDOW = 30; + +export type AppOrigin = "spark" | "apm"; + +export interface ContributorRank { + name: string; + count: number; +} + +/** + * 解析 contributor 字段。 + * 原始格式形如 `name`,可能存在多个贡献者以 `;` / `;` 分隔。 + * 聚合与展示统一去除 `<...>` 取显示名。 + */ +export function parseContributors(raw: string | undefined): string[] { + if (!raw) return []; + const names: string[] = []; + for (const part of raw.split(/[;;]/)) { + const trimmed = part.trim(); + if (!trimmed) continue; + const name = trimmed.replace(/<[^>]*>/g, "").trim(); + if (name) names.push(name); + } + return names; +} + +/** 对已过滤来源的应用列表,按 contributor 出现次数计数 */ +function countContributors(apps: App[]): Map { + const counter = new Map(); + for (const app of apps) { + for (const name of parseContributors(app.contributor)) { + counter.set(name, (counter.get(name) || 0) + 1); + } + } + return counter; +} + +function toRanks(counter: Map, topN: number): ContributorRank[] { + return [...counter.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) + .slice(0, topN); +} + +/** 贡献荣耀榜:按来源统计每个贡献者的上榜应用数,取 Top N */ +export function aggregateContributors( + apps: App[], + origin: AppOrigin, + topN: number = TOP_N, +): ContributorRank[] { + const filtered = apps.filter((a) => a.origin === origin); + return toRanks(countContributors(filtered), topN); +} + +/** 应用更新排行:按 `update` 字段倒序,取某来源的 Top N */ +export function topByUpdate( + apps: App[], + origin: AppOrigin, + topN: number = TOP_N, +): App[] { + return apps + .filter((a) => a.origin === origin && a.update) + .sort((a, b) => (b.update || "").localeCompare(a.update || "")) + .slice(0, topN); +} + +/** 更新荣耀榜:取某来源「最近更新」窗口内的应用,聚合其贡献者,取 Top N */ +export function topUpdatedContributors( + apps: App[], + origin: AppOrigin, + recentN: number = RECENT_UPDATE_WINDOW, + topN: number = TOP_N, +): ContributorRank[] { + const recent = topByUpdate(apps, origin, recentN).filter( + (a) => a.origin === origin, + ); + return toRanks(countContributors(recent), topN); +} From 4f772ba7ebf7d830c0a809de928bda9c5fc0f5a1 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Mon, 3 Aug 2026 11:24:32 +0800 Subject: [PATCH 02/36] =?UTF-8?q?fix:=20=E5=AE=89=E5=85=A8=E4=B8=8E?= =?UTF-8?q?=E5=81=A5=E5=A3=AE=E6=80=A7=E6=8E=92=E6=9F=A5=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=88IPC=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=20awai?= =?UTF-8?q?t=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", From 1569dad866cd8221defb869fc2c0657422179595 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Mon, 3 Aug 2026 12:05:57 +0800 Subject: [PATCH 03/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=86=85=E5=AE=B9=E5=9B=A0=20icon=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=A0=A1=E9=AA=8C=E8=A2=AB=E5=85=A8=E9=83=A8=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E7=9A=84=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回顾:上轮健壮性修改中,loadHome 对 homelinks.json 的解析新增了 'if (!url || !icon) continue' 的硬性校验。但远程 homelinks.json 实际 仅提供 name/more/imgUrl/type/url,并不含 icon 字段(首页图片统一由 imgUrl 提供),导致所有链接因缺少 icon 被跳过,首页区域1 变空白。 修正: - App.vue loadHome:移除对 icon 的强制校验,仅校验 url 必需;icon 缺省为空串以兼容类型 - typedefinition HomeLink.icon:由必需 string 改为可选,反映真实数据源契约 vue-tsc --noEmit 通过,已重新打包验证(5.2.1.2-test)。 --- src/App.vue | 5 +++-- src/global/typedefinition.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/App.vue b/src/App.vue index 5220d5e0..3e82745d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1374,10 +1374,11 @@ const loadHome = async () => { const name = (l.Name as string) || (l.name as string) || ""; if (!name) continue; // 跳过空名称,避免空字符串污染 seenNames 与去重逻辑 if (seenNames.has(name)) continue; // 已由更高优先级来源(spark)占据 - // 校验 HomeLink 必需字段,缺失则跳过,避免推入不完整对象导致运行时错误 + // 仅校验 url 必需;远程 homelinks.json 不含 icon 字段(图片由 imgUrl 提供), + // 故 icon 不作为硬性校验,缺省为空串以兼容 HomeLink 类型。 const url = (l.Url as string) || (l.url as string) || ""; + if (!url) continue; const icon = (l.Icon as string) || (l.icon as string) || ""; - if (!url || !icon) continue; seenNames.add(name); homeLinks.value.push({ ...l, diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index 6d87aba1..64ac8a5f 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -231,7 +231,8 @@ export interface CategoryInfo { export interface HomeLink { name: string; url: string; - icon: string; + // 数据源(homelinks.json)并不提供 icon 字段,图片统一由 imgUrl 提供,故设为可选 + icon?: string; more?: string; imgUrl?: string; type?: string; From 3816703303fb63d4078762f6bde99b48fc930113 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Mon, 3 Aug 2026 14:17:45 +0800 Subject: [PATCH 04/36] =?UTF-8?q?fix:=20=E5=AE=89=E5=85=A8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=E2=80=94=E2=80=94queue-install=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E9=81=8D=E5=8E=86=E6=A0=A1=E9=AA=8C=20+=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E8=BF=9C=E7=A8=8B=E6=95=B0=E6=8D=AE=E6=98=BE=E5=BC=8F=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 PR 安全审查的修复: 1. queue-install 路径遍历(阻断项,真实风险): install-manager.ts 直接用渲染进程传入的 pkgname 拼装下载目录 /tmp/spark-store/download/ 与 ssinstall 命令参数 /, 此前未做任何校验。复用模块顶部 PKGNAME_PATTERN 对 pkgname 与 filename 做白名单校验, 拦截含 ../、/ 或非法字符的输入,杜绝路径遍历。 2. 首页远程数据展开(阻断项,纵深防御): App.vue loadHome 此前用 { ...l } 直接展开远程 homelinks.json 到响应式数组, 可能注入未知属性。改为显式提取 name/url/icon/more/imgUrl/type/origin 已知字段构造, 仅保留可信字段(url 仍做必需校验以保首页正常渲染)。 注:check-installed 白名单(审查第1项)此前已在 4f772ba7 通过 parseAppPayload 修复,本次无改动。 vue-tsc --noEmit 通过。 --- electron/main/backend/install-manager.ts | 10 ++++++++++ src/App.vue | 10 +++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index 02bea18e..4f8e2a40 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -297,6 +297,16 @@ ipcMain.on("queue-install", async (event, download_json) => { return; } + // 包名/文件名白名单校验:防止路径遍历(如 ../../)或非法字符进入下载目录与安装命令构建 + if (!PKGNAME_PATTERN.test(pkgname)) { + logger.warn(`queue-install invalid pkgname: ${pkgname}`); + return; + } + if (filename && !PKGNAME_PATTERN.test(filename)) { + logger.warn(`queue-install invalid filename: ${filename}`); + return; + } + logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`); const webContents = event.sender; diff --git a/src/App.vue b/src/App.vue index 3e82745d..b98d945b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1380,13 +1380,17 @@ const loadHome = async () => { if (!url) continue; const icon = (l.Icon as string) || (l.icon as string) || ""; seenNames.add(name); - homeLinks.value.push({ - ...l, + // 显式提取已知字段构造,避免通过展开运算符 { ...l } 把远程不可信数据中的未知属性注入响应式状态 + const safeLink: HomeLink = { name, url, icon, + more: (l.more as string) || undefined, + imgUrl: (l.imgUrl as string) || undefined, + type: (l.type as string) || undefined, origin: mode, - } as HomeLink); + }; + homeLinks.value.push(safeLink); } } } catch (e) { From c1d2bef38a5a4cc669c4f22ece0784bd16b9c3d7 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Mon, 3 Aug 2026 14:35:05 +0800 Subject: [PATCH 05/36] =?UTF-8?q?ui:=20=E9=9A=90=E8=97=8F=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E8=87=B4=E8=B0=A2=E5=8D=A1=E7=89=87=EF=BC=88=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E4=BF=9D=E7=95=99=EF=BC=8Cv-if=3Dfalse=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/HomeView.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/HomeView.vue b/src/components/HomeView.vue index a1438141..73f67d98 100644 --- a/src/components/HomeView.vue +++ b/src/components/HomeView.vue @@ -120,8 +120,8 @@
- - + +
From 0d580c4d47129d1f29a762c4db1a023eb0323f64 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Thu, 6 Aug 2026 20:51:47 +0800 Subject: [PATCH 06/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20before-quit?= =?UTF-8?q?=20=E4=B8=AD=20mainWindow=20=E6=9C=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E6=8A=A5=E9=94=99=EF=BC=8C=E5=B9=B6=E4=BC=98=E5=8C=96=E6=8E=92?= =?UTF-8?q?=E8=A1=8C=E6=A6=9C=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - electron/main/index.ts: flushSaveBounds 改用模块级 win 变量, 修复关闭应用时 mainWindow is not defined 的 ReferenceError - src/App.vue: loadRanking 并发数降至 15;loadHome 多来源请求并行化 - src/modules/ranking.ts: parseContributors 增强异常格式边界处理 --- electron/main/index.ts | 15 ++++++++ src/App.vue | 81 +++++++++++++++++++++++------------------- src/modules/ranking.ts | 5 +-- 3 files changed, 63 insertions(+), 38 deletions(-) diff --git a/electron/main/index.ts b/electron/main/index.ts index 9a37e241..6159b592 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -361,6 +361,16 @@ function saveWindowState(state: WindowState): void { } let saveBoundsTimer: NodeJS.Timeout | null = null; +function flushSaveBounds(): void { + if (saveBoundsTimer) { + clearTimeout(saveBoundsTimer); + saveBoundsTimer = null; + } + if (win && !win.isDestroyed()) { + const { width, height, x, y } = win.getBounds(); + saveWindowState({ width, height, x, y, maximized: win.isMaximized() }); + } +} function scheduleSaveBounds(winInstance: BrowserWindow): void { if (saveBoundsTimer) clearTimeout(saveBoundsTimer); saveBoundsTimer = setTimeout(() => { @@ -376,6 +386,11 @@ function scheduleSaveBounds(winInstance: BrowserWindow): void { }, 400); } +// 应用退出前立即持久化(防抖 400ms 可能在快速关闭时丢失最后一次状态) +app.on("before-quit", () => { + flushSaveBounds(); +}); + async function createWindow() { const saved = loadWindowState(); // 恢复时:若上次窗口过大(>1600x900,通常为全屏/最大化),回退到默认尺寸并居中, diff --git a/src/App.vue b/src/App.vue index b98d945b..29a55f44 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1316,7 +1316,7 @@ const loadRanking = async () => { const gen = ++rankingGeneration; rankingLoading.value = true; const all = apps.value.slice(); - const CONCURRENCY = 64; + const CONCURRENCY = 15; const results: App[] = []; for (let i = 0; i < all.length; i += CONCURRENCY) { if (gen !== rankingGeneration) { @@ -1359,42 +1359,51 @@ const loadHome = async () => { // 按名称去重,spark 优先:同名链接 spark 覆盖 apm const seenNames = new Set(); - for (const mode of modes) { - const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`; - const base = `${APM_STORE_BASE_URL}/${finalArch}/home`; - - // homelinks.json - try { - const res = await fetch(`${base}/homelinks.json`); - if (res.ok) { - 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 as string) || (l.name as string) || ""; - if (!name) continue; // 跳过空名称,避免空字符串污染 seenNames 与去重逻辑 - if (seenNames.has(name)) continue; // 已由更高优先级来源(spark)占据 - // 仅校验 url 必需;远程 homelinks.json 不含 icon 字段(图片由 imgUrl 提供), - // 故 icon 不作为硬性校验,缺省为空串以兼容 HomeLink 类型。 - const url = (l.Url as string) || (l.url as string) || ""; - if (!url) continue; - const icon = (l.Icon as string) || (l.icon as string) || ""; - seenNames.add(name); - // 显式提取已知字段构造,避免通过展开运算符 { ...l } 把远程不可信数据中的未知属性注入响应式状态 - const safeLink: HomeLink = { - name, - url, - icon, - more: (l.more as string) || undefined, - imgUrl: (l.imgUrl as string) || undefined, - type: (l.type as string) || undefined, - origin: mode, - }; - homeLinks.value.push(safeLink); - } + // 并行请求各来源的 homelinks.json,缩短首页加载耗时 + const modeResults = await Promise.all( + modes.map(async (mode) => { + const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`; + const base = `${APM_STORE_BASE_URL}/${finalArch}/home`; + try { + const res = await fetch(`${base}/homelinks.json`); + if (res.ok) return { mode, raw: (await res.json()) as unknown }; + } catch (e) { + console.warn(`Failed to load ${mode} homelinks.json`, e); } - } catch (e) { - console.warn(`Failed to load ${mode} homelinks.json`, e); + return { mode, raw: undefined }; + }), + ); + + for (const { mode, raw } of modeResults) { + if (!raw) continue; + // 校验 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 as string) || (l.name as string) || ""; + if (!name) continue; // 跳过空名称,避免空字符串污染 seenNames 与去重逻辑 + if (seenNames.has(name)) continue; // 已由更高优先级来源(spark)占据 + // 仅校验 url 必需;远程 homelinks.json 不含 icon 字段(图片由 imgUrl 提供), + // 故 icon 不作为硬性校验,缺省为空串以兼容 HomeLink 类型。 + const url = (l.Url as string) || (l.url as string) || ""; + if (!url) continue; + const icon = (l.Icon as string) || (l.icon as string) || ""; + seenNames.add(name); + // 显式提取已知字段构造,避免通过展开运算符 { ...l } 把远程不可信数据中的未知属性注入响应式状态 + const safeLink: HomeLink = { + name, + url, + icon, + more: (l.more as string) || undefined, + imgUrl: (l.imgUrl as string) || undefined, + type: (l.type as string) || undefined, + origin: mode, + }; + homeLinks.value.push(safeLink); } } } catch (error: unknown) { diff --git a/src/modules/ranking.ts b/src/modules/ranking.ts index 0b8cb658..fbac423e 100644 --- a/src/modules/ranking.ts +++ b/src/modules/ranking.ts @@ -24,13 +24,14 @@ export interface ContributorRank { * 聚合与展示统一去除 `<...>` 取显示名。 */ export function parseContributors(raw: string | undefined): string[] { - if (!raw) return []; + if (!raw || typeof raw !== "string") return []; const names: string[] = []; for (const part of raw.split(/[;;]/)) { const trimmed = part.trim(); if (!trimmed) continue; + // 去除 <...> 包裹的邮箱,取显示名;长度 1~50 过滤空字符串/异常超长 const name = trimmed.replace(/<[^>]*>/g, "").trim(); - if (name) names.push(name); + if (name.length > 0 && name.length <= 50) names.push(name); } return names; } From ae584730c4303473af5c79eb96be74dcb3a6a70e Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Thu, 6 Aug 2026 22:06:43 +0800 Subject: [PATCH 07/36] =?UTF-8?q?refactor:=20=E5=A4=9A=E7=BB=B4=E5=BA=A6?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=EF=BC=88?= =?UTF-8?q?=E5=AE=89=E5=85=A8/=E6=B8=85=E7=90=86/=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/App.vue: - fetchAppFromStore 的 category/pkgname 路径参数 encodeURIComponent 编码(防路径穿越) - img_urls JSON.parse 增加 try/catch,解析失败回退空数组(两处) - onUnmounted 统一移除 hashchange/resize/keydown 及全部 IPC 监听,修复内存泄漏 - 提取 createFallbackApp 消除 openDetail 重复回退构造 - 收集并统一停止等待 loading 的一次性 watcher,闭环生命周期清理 - src/global/typedefinition.ts: 移除 CategoryInfo/HomeLink 宽泛索引签名, CategoryInfo 改用显式 extra 字段 - electron/main/backend/install-manager.ts: 清理冗余 eslint-disable 并修正缩进 (remove-installed/check-installed 已通过 parseAppPayload 完成 pkgname 校验,无需改动) --- electron/main/backend/install-manager.ts | 53 ++-- src/App.vue | 384 +++++++++++++---------- src/global/typedefinition.ts | 4 +- 3 files changed, 249 insertions(+), 192 deletions(-) diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index 4f8e2a40..4d63e2b0 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -1269,7 +1269,6 @@ ipcMain.handle("show-apm-install-dialog", async (event) => { return { success: false, cancelled: true }; }); -// eslint-disable-next-line @typescript-eslint/no-explicit-any ipcMain.handle( "uninstall-installed", async ( @@ -1283,42 +1282,42 @@ ipcMain.handle( } const { pkgname, origin } = parsed; - const superUserCmd = await checkSuperUserCommand(); - const execCommand = superUserCmd || SHELL_CALLER_PATH; - const execParams = superUserCmd ? [SHELL_CALLER_PATH] : []; + const superUserCmd = await checkSuperUserCommand(); + const execCommand = superUserCmd || SHELL_CALLER_PATH; + const execParams = superUserCmd ? [SHELL_CALLER_PATH] : []; - if (origin === "apm") { - execParams.push("apm", "remove", "-y", pkgname); - } else { - execParams.push("aptss", "remove", "-y", pkgname); - } + if (origin === "apm") { + execParams.push("apm", "remove", "-y", pkgname); + } else { + execParams.push("aptss", "remove", "-y", pkgname); + } - const { code, stdout, stderr } = await runCommandCapture( - execCommand, - execParams, - ); - const success = code === 0; + const { code, stdout, stderr } = await runCommandCapture( + execCommand, + execParams, + ); + const success = code === 0; - if (success) { - logger.info(`卸载完成: ${pkgname}`); - } else { - logger.error(`卸载失败: ${pkgname} ${stderr || stdout}`); - } + if (success) { + logger.info(`卸载完成: ${pkgname}`); + } else { + logger.error(`卸载失败: ${pkgname} ${stderr || stdout}`); + } - return { - success, - message: success - ? "卸载完成" - : stderr || stdout || `卸载失败,退出码 ${code}`, - }; -}); + return { + success, + message: success + ? "卸载完成" + : stderr || stdout || `卸载失败,退出码 ${code}`, + }; + }, +); interface LaunchAppPayload { pkgname: string; origin?: "spark" | "apm"; } - ipcMain.handle( "launch-app", async ( diff --git a/src/App.vue b/src/App.vue index 29a55f44..8bd86b7f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -897,10 +897,24 @@ const fetchAppFromStore = async ( try { const arch = window.apm_store.arch || "amd64"; const finalArch = origin === "spark" ? `${arch}-store` : `${arch}-apm`; - const appJsonUrl = `${APM_STORE_BASE_URL}/${finalArch}/${category}/${pkgname}/app.json`; + // 路径参数需编码,避免特殊字符破坏请求路径或造成路径穿越 + const appJsonUrl = `${APM_STORE_BASE_URL}/${finalArch}/${encodeURIComponent( + category, + )}/${encodeURIComponent(pkgname)}/app.json`; const response = await fetch(appJsonUrl); if (!response.ok) return null; const appJson = await response.json(); + // img_urls 可能为字符串形式的 JSON,解析失败时安全回退为空数组 + const parsedImgUrls = (() => { + if (typeof appJson.img_urls === "string") { + try { + return JSON.parse(appJson.img_urls) as string[]; + } catch { + return []; + } + } + return (appJson.img_urls as string[]) || []; + })(); return { name: appJson.Name || "", pkgname: appJson.Pkgname || pkgname, @@ -914,10 +928,7 @@ const fetchAppFromStore = async ( size: appJson.Size || "", more: appJson.More || "", tags: appJson.Tags || "", - img_urls: - typeof appJson.img_urls === "string" - ? (JSON.parse(appJson.img_urls) as string[]) - : appJson.img_urls || [], + img_urls: parsedImgUrls, icons: appJson.icons || "", category: category, origin: origin, @@ -944,6 +955,31 @@ interface OpenDetailInput extends Partial { origin?: "spark" | "apm"; } +// 提取远程/本地均无匹配时的回退 App 构造(两处兜底分支共用,避免重复字段映射) +const createFallbackApp = ( + raw: Record, + pkgname: string, + category: string, +): App => ({ + name: (raw.name as string) || "", + pkgname, + version: (raw.version as string) || "", + filename: (raw.filename as string) || "", + category, + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: (raw.more as string) || "", + tags: "", + img_urls: [], + icons: "", + origin: (raw.origin as "spark" | "apm") || "apm", + currentStatus: "not-installed", +}); + const openDetail = async (app: App | OpenDetailInput) => { // 提取 pkgname 和 category(必须存在) const pkgname = app?.pkgname; @@ -1029,26 +1065,11 @@ const openDetail = async (app: App | OpenDetailInput) => { finalApp = fullApp; } else { // 两个仓库都没有找到,且本地也没有,构造一个最小可用的 App 对象 - finalApp = { - name: ((app as Record).name as string) || "", - pkgname: pkgname, - version: ((app as Record).version as string) || "", - filename: ((app as Record).filename as string) || "", - category: category, - torrent_address: "", - author: "", - contributor: "", - website: "", - update: "", - size: "", - more: ((app as Record).more as string) || "", - tags: "", - img_urls: [], - icons: "", - origin: - ((app as Record).origin as "spark" | "apm") || "apm", - currentStatus: "not-installed", - } as App; + finalApp = createFallbackApp( + app as Record, + pkgname, + category, + ); } } else { // 非 HomeView 来源,使用原来的逻辑 @@ -1056,26 +1077,11 @@ const openDetail = async (app: App | OpenDetailInput) => { finalApp = fullApp; } else { // 构造一个最小可用的 App 对象 - finalApp = { - name: ((app as Record).name as string) || "", - pkgname: pkgname, - version: ((app as Record).version as string) || "", - filename: ((app as Record).filename as string) || "", - category: category, - torrent_address: "", - author: "", - contributor: "", - website: "", - update: "", - size: "", - more: ((app as Record).more as string) || "", - tags: "", - img_urls: [], - icons: "", - origin: - ((app as Record).origin as "spark" | "apm") || "apm", - currentStatus: "not-installed", - } as App; + finalApp = createFallbackApp( + app as Record, + pkgname, + category, + ); } } @@ -1118,7 +1124,10 @@ const openDetail = async (app: App | OpenDetailInput) => { } } } - if (preferred && (preferred === "spark" ? finalApp.sparkApp : finalApp.apmApp)) { + if ( + preferred && + (preferred === "spark" ? finalApp.sparkApp : finalApp.apmApp) + ) { finalApp.viewingOrigin = preferred; } else if (sparkInstalled && !apmInstalled) { // 无点击来源时:仅一个仓库已安装则优先展示已安装版本 @@ -1550,7 +1559,10 @@ const loadHomeListApps = async (entryId: string) => { try { const path = `/${finalArch}${jsonUrl}`; const rawApps = - (await fetchWithRetry[]>(path, rootAbortController.signal)) || []; + (await fetchWithRetry[]>( + path, + rootAbortController.signal, + )) || []; const apps = parseAppList(rawApps, mode); for (const app of apps) { if (!app.pkgname || seenPkgnames.has(app.pkgname)) continue; @@ -2963,10 +2975,16 @@ const normalizeAppJson = ( size: appJson.Size, more: appJson.More, tags: appJson.Tags, - img_urls: - typeof appJson.img_urls === "string" - ? (JSON.parse(appJson.img_urls) as string[]) - : appJson.img_urls, + img_urls: (() => { + if (typeof appJson.img_urls === "string") { + try { + return JSON.parse(appJson.img_urls) as string[]; + } catch { + return []; + } + } + return (appJson.img_urls as string[]) || []; + })(), icons: appJson.icons, category: category, origin: origin, @@ -3140,7 +3158,10 @@ const loadApps = async (onFirstBatch?: () => void) => { const path = `/${finalArch}/${category}/applist.json`; logger.info(`加载分类: ${category} (来源: ${mode})`); - const categoryApps = await fetchWithRetry(path, rootAbortController.signal); + const categoryApps = await fetchWithRetry( + path, + rootAbortController.signal, + ); const normalizedApps = (categoryApps || []).map((appJson) => normalizeAppJson(appJson, category, mode as "spark" | "apm"), @@ -3205,15 +3226,135 @@ const handleWindowResize = () => { }, 400); }; +// —— 以下为可复用的事件/IPC 监听处理器(命名函数,便于 onUnmounted 统一移除)—— +// 收集"等待 loading 完成后执行一次"的 watcher,便于卸载时统一停止,避免泄漏 +const pendingWatchers: Array<() => void> = []; +const registerOnceWatcher = (stop: () => void): void => { + pendingWatchers.push(stop); +}; + +const handleHashChange = () => { + isSubmitterView.value = window.location.hash === "#submitter"; +}; + +const handleKeydown = (e: KeyboardEvent) => { + if (showPreview.value) { + if (e.key === "Escape") closeScreenPreview(); + if (e.key === "ArrowLeft") prevScreen(); + if (e.key === "ArrowRight") nextScreen(); + } + if (showModal.value && e.key === "Escape") { + closeDetail(); + } +}; + +const handleDeepLinkUpdate = () => { + if (loading.value) { + const stop = watch(loading, (val) => { + if (!val) { + openUpdateModal(); + stop(); + const idx = pendingWatchers.indexOf(stop); + if (idx >= 0) pendingWatchers.splice(idx, 1); + } + }); + registerOnceWatcher(stop); + } else { + openUpdateModal(); + } +}; + +const handleDeepLinkInstalled = () => { + if (loading.value) { + const stop = watch(loading, (val) => { + if (!val) { + openInstalledModal(); + stop(); + const idx = pendingWatchers.indexOf(stop); + if (idx >= 0) pendingWatchers.splice(idx, 1); + } + }); + registerOnceWatcher(stop); + } else { + openInstalledModal(); + } +}; + +const handleTriggerApmInstallDialog = () => { + showApmInstallDialog.value = true; +}; + +const handleDeepLinkInstall = (_event: IpcRendererEvent, pkgname: string) => { + const tryOpen = () => { + const target = apps.value.find((a) => a.pkgname === pkgname); + if (target) { + openDetail(target); + } else { + logger.warn(`Deep link: app ${pkgname} not found`); + } + }; + if (loading.value) { + const stop = watch(loading, (val) => { + if (!val) { + tryOpen(); + stop(); + const idx = pendingWatchers.indexOf(stop); + if (idx >= 0) pendingWatchers.splice(idx, 1); + } + }); + registerOnceWatcher(stop); + } else { + tryOpen(); + } +}; + +const handleDeepLinkSearch = ( + _event: IpcRendererEvent, + data: { pkgname: string }, +) => { + const tryOpen = () => { + currentView.value = "default"; + activeTab.value = "all"; + const target = apps.value.find((a) => a.pkgname === data.pkgname); + if (target) { + openDetail({ ...target, _fromDeepLink: true }); + } else { + searchQuery.value = data.pkgname; + logger.warn( + `Deep link: app ${data.pkgname} not found, fallback to search`, + ); + } + }; + if (loading.value) { + const stop = watch(loading, (val) => { + if (!val) { + tryOpen(); + stop(); + const idx = pendingWatchers.indexOf(stop); + if (idx >= 0) pendingWatchers.splice(idx, 1); + } + }); + registerOnceWatcher(stop); + } else { + tryOpen(); + } +}; + +const handleRemoveComplete = ( + _event: IpcRendererEvent, + payload: ChannelPayload, +) => { + const pkgname = currentApp.value?.pkgname; + if (payload.success && pkgname) { + removeDownloadItem(pkgname); + } +}; + // 生命周期钩子 onMounted(async () => { initTheme(); updateCenterStore.bind(); - const handleHashChange = () => { - isSubmitterView.value = window.location.hash === "#submitter"; - }; - handleHashChange(); window.addEventListener("hashchange", handleHashChange); @@ -3273,121 +3414,23 @@ onMounted(async () => { logger.info("所有应用数据加载完成"); // 设置键盘导航 - document.addEventListener("keydown", (e) => { - if (showPreview.value) { - if (e.key === "Escape") closeScreenPreview(); - if (e.key === "ArrowLeft") prevScreen(); - if (e.key === "ArrowRight") nextScreen(); - } - if (showModal.value && e.key === "Escape") { - closeDetail(); - } - }); + document.addEventListener("keydown", handleKeydown); // Deep link Handlers - window.ipcRenderer.on("deep-link-update", () => { - if (loading.value) { - const stop = watch(loading, (val) => { - if (!val) { - openUpdateModal(); - stop(); - } - }); - } else { - openUpdateModal(); - } - }); - - window.ipcRenderer.on("deep-link-installed", () => { - if (loading.value) { - const stop = watch(loading, (val) => { - if (!val) { - openInstalledModal(); - stop(); - } - }); - } else { - openInstalledModal(); - } - }); - - window.ipcRenderer.on("trigger-apm-install-dialog", () => { - showApmInstallDialog.value = true; - }); - + window.ipcRenderer.on("deep-link-update", handleDeepLinkUpdate); + window.ipcRenderer.on("deep-link-installed", handleDeepLinkInstalled); window.ipcRenderer.on( - "deep-link-install", - (_event: IpcRendererEvent, pkgname: string) => { - const tryOpen = () => { - const target = apps.value.find((a) => a.pkgname === pkgname); - if (target) { - openDetail(target); - } else { - logger.warn(`Deep link: app ${pkgname} not found`); - } - }; - - if (loading.value) { - const stop = watch(loading, (val) => { - if (!val) { - tryOpen(); - stop(); - } - }); - } else { - tryOpen(); - } - }, - ); - - window.ipcRenderer.on( - "deep-link-search", - (_event: IpcRendererEvent, data: { pkgname: string }) => { - // 根据包名直接打开应用详情 - const tryOpen = () => { - // 先切换到"全部应用"分类 - currentView.value = "default"; - activeTab.value = "all"; - // 使用类似 HomeView 的方式打开应用,从两个仓库获取完整信息 - const target = apps.value.find((a) => a.pkgname === data.pkgname); - if (target) { - openDetail({ ...target, _fromDeepLink: true }); - } else { - // 如果找不到应用,回退到搜索模式 - searchQuery.value = data.pkgname; - logger.warn( - `Deep link: app ${data.pkgname} not found, fallback to search`, - ); - } - }; - - if (loading.value) { - const stop = watch(loading, (val) => { - if (!val) { - tryOpen(); - stop(); - } - }); - } else { - tryOpen(); - } - }, + "trigger-apm-install-dialog", + handleTriggerApmInstallDialog, ); + window.ipcRenderer.on("deep-link-install", handleDeepLinkInstall); + window.ipcRenderer.on("deep-link-search", handleDeepLinkSearch); window.ipcRenderer.on( "install-complete", handleInstallCompleteForDownloadRecord, ); - - window.ipcRenderer.on( - "remove-complete", - (_event: IpcRendererEvent, payload: ChannelPayload) => { - const pkgname = currentApp.value?.pkgname; - if (payload.success && pkgname) { - removeDownloadItem(pkgname); - } - }, - ); + window.ipcRenderer.on("remove-complete", handleRemoveComplete); window.ipcRenderer.send("renderer-ready", { status: true }); logger.info("Renderer process is ready!"); @@ -3396,11 +3439,26 @@ onMounted(async () => { onUnmounted(() => { rootAbortController.abort(); updateCenterStore.unbind(); + // 停止仍挂起的"等待 loading 完成后执行一次"的 watcher,避免泄漏 + for (const stop of pendingWatchers.splice(0)) stop(); + // 移除 window / document 监听 + window.removeEventListener("hashchange", handleHashChange); + window.removeEventListener("resize", handleWindowResize); + document.removeEventListener("keydown", handleKeydown); + // 移除所有 IPC 监听 + window.ipcRenderer.off("deep-link-update", handleDeepLinkUpdate); + window.ipcRenderer.off("deep-link-installed", handleDeepLinkInstalled); + window.ipcRenderer.off( + "trigger-apm-install-dialog", + handleTriggerApmInstallDialog, + ); + window.ipcRenderer.off("deep-link-install", handleDeepLinkInstall); + window.ipcRenderer.off("deep-link-search", handleDeepLinkSearch); window.ipcRenderer.off( "install-complete", handleInstallCompleteForDownloadRecord, ); - window.removeEventListener("resize", handleWindowResize); + window.ipcRenderer.off("remove-complete", handleRemoveComplete); }); // 观察器 diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index 64ac8a5f..fdb83134 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -225,7 +225,8 @@ export interface CategoryInfo { zh: string; origins?: string[]; origin?: "spark" | "apm"; - [k: string]: unknown; + // 保留扩展点,避免使用宽泛的索引签名削弱类型安全 + extra?: Record; } export interface HomeLink { @@ -237,7 +238,6 @@ export interface HomeLink { imgUrl?: string; type?: string; origin?: "spark" | "apm"; - [k: string]: unknown; } export interface SidebarEntry { From 88499a56f8784ae37d02e3048cb6c509e0d06ab8 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Fri, 7 Aug 2026 10:45:44 +0800 Subject: [PATCH 08/36] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E4=BC=98=E5=85=88=E6=98=BE=E7=A4=BA=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E3=80=8D=E8=AE=BE=E7=BD=AE=EF=BC=88=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=80=89=E6=8B=A9/Spark=E4=BC=98=E5=85=88/APM=E4=BC=98?= =?UTF-8?q?=E5=85=88=EF=BC=8C=E9=BB=98=E8=AE=A4=E8=87=AA=E5=8A=A8=E9=80=89?= =?UTF-8?q?=E6=8B=A9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:在应用设置中新增标签优先显示策略配置,进入应用详情页时按所选策略决定 默认展示的来源(Spark/APM)标签;选中的标签即为安装时对应的来源。 实现: - 新增 src/global/tagPriority.ts:策略('auto'|'spark'|'apm')共享模块,localStorage 持久化, 通过共享 ref 在设置页与详情页间同步(纯前端显示偏好,不触及后端安装脚本)。 - SettingsModal.vue:在「安装设置」中新增三选项分段控件(默认选中「自动选择」), 打开时加载、变更时即时保存。 - AppDetailModal.vue:新增 computeDefaultViewingOrigin,按策略计算默认 viewingOrigin: 自动选择→getHybridDefaultOrigin(应用配置优先级);Spark/APM 优先→优先选中对应标签, 若应用无该来源标签则回退优先级策略;仅有一个来源标签时默认展示该标签; 父组件显式指定的 viewingOrigin 仍具最高优先级。 vue-tsc --noEmit 通过。 --- src/components/AppDetailModal.vue | 52 ++++++++++++++++------ src/components/SettingsModal.vue | 72 +++++++++++++++++++++++++++++++ src/global/tagPriority.ts | 63 +++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 src/global/tagPriority.ts diff --git a/src/components/AppDetailModal.vue b/src/components/AppDetailModal.vue index 27c65ea2..4c334e8b 100644 --- a/src/components/AppDetailModal.vue +++ b/src/components/AppDetailModal.vue @@ -577,6 +577,10 @@ import { APM_STORE_BASE_URL, getHybridDefaultOrigin, } from "../global/storeConfig"; +import { + getTagPriorityStrategy, + type TagPriorityStrategy, +} from "../global/tagPriority"; // 评论功能暂时关闭 // import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity"; import type { App, AppReview, ReviewTags } from "../global/typedefinition"; @@ -632,25 +636,45 @@ const openWebsite = (url: string) => { } }; +// 根据「标签优先显示策略」与应用的可用来源标签,计算默认展示的来源 +const computeDefaultViewingOrigin = ( + app: App, + strategy: TagPriorityStrategy, +): "spark" | "apm" => { + // 父组件已显式指定展示来源(如从已安装应用页按特定来源打开),优先级最高 + if (app.viewingOrigin) return app.viewingOrigin; + + // 非合并应用只有一个来源标签,直接展示该标签 + if (!app.isMerged) return app.origin; + + // 合并应用:available 为实际存在的来源标签 + const available: Array<"spark" | "apm"> = []; + if (app.sparkApp) available.push("spark"); + if (app.apmApp) available.push("apm"); + if (available.length === 0) return app.origin; // 兜底 + if (available.length === 1) return available[0]; // 仅有一个标签时默认展示该标签 + + // 按策略优先选择对应标签 + const preferred: "spark" | "apm" | null = + strategy === "spark" ? "spark" : strategy === "apm" ? "apm" : null; + if (preferred && available.includes(preferred)) { + return preferred; + } + + // 回退到应用配置的优先级策略(getHybridDefaultOrigin),并约束在可用标签范围内 + const auto = getHybridDefaultOrigin(app.sparkApp ?? app.apmApp ?? app); + return available.includes(auto) ? auto : available[0]; +}; + watch( () => props.app, (newApp: App | null) => { isIconLoaded.value = false; if (newApp) { - if (newApp.isMerged) { - // 若父组件已根据安装状态设置了优先展示的版本,则使用 - // 否则根据优先级配置决定默认来源 - if (newApp.viewingOrigin) { - viewingOrigin.value = newApp.viewingOrigin; - } else if (newApp.sparkApp) { - // 使用优先级配置决定默认来源 - viewingOrigin.value = getHybridDefaultOrigin(newApp.sparkApp); - } else { - viewingOrigin.value = "apm"; - } - } else { - viewingOrigin.value = newApp.origin; - } + viewingOrigin.value = computeDefaultViewingOrigin( + newApp, + getTagPriorityStrategy(), + ); } }, { immediate: true }, diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue index ddb5f80b..7cc55d35 100644 --- a/src/components/SettingsModal.vue +++ b/src/components/SettingsModal.vue @@ -118,6 +118,51 @@ />
+ + +
+
+
+ +
+
+

+ 标签优先显示策略 +

+

+ 进入应用详情页时默认展示的来源标签 +

+
+ +
+
+
+
@@ -135,6 +180,11 @@ diff --git a/src/global/tagPriority.ts b/src/global/tagPriority.ts new file mode 100644 index 00000000..eeed429e --- /dev/null +++ b/src/global/tagPriority.ts @@ -0,0 +1,63 @@ +// 标签优先显示策略 +// +// 用于在「应用详情页」决定进入时默认展示的来源(Spark / APM)标签。 +// 该配置为纯前端显示偏好(选中的标签即安装时对应的来源),不涉及后端安装脚本, +// 因此使用 localStorage 持久化并通过共享 ref 在设置页与详情页之间共享。 +// +// 策略取值: +// - "auto" : 自动选择 —— 按应用配置的优先级(getHybridDefaultOrigin)自动选中 +// - "spark" : Spark 优先 —— 默认选中 Spark 标签;若无 Spark 标签则回退优先级策略 +// - "apm" : APM 优先 —— 默认选中 APM 标签;若无 APM 标签则回退优先级策略 +// 注:当应用仅有一个来源标签时,无论策略如何都默认展示该标签。 + +import { ref } from "vue"; + +export type TagPriorityStrategy = "auto" | "spark" | "apm"; + +const STORAGE_KEY = "spark-store-tag-priority-strategy"; + +const VALID_STRATEGIES: TagPriorityStrategy[] = ["auto", "spark", "apm"]; + +// 共享响应式引用:设置页写入、详情页读取,保证跨组件同步 +const strategy = ref("auto"); + +let loaded = false; + +const readFromStorage = (): TagPriorityStrategy => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && (VALID_STRATEGIES as string[]).includes(stored)) { + return stored as TagPriorityStrategy; + } + } catch { + // localStorage 不可用时静默降级为默认值 + } + return "auto"; +}; + +// 首次从持久化读取(仅执行一次),可在应用启动时调用 +export const initTagPriorityStrategy = (): void => { + if (loaded) return; + strategy.value = readFromStorage(); + loaded = true; +}; + +// 同步获取当前策略(确保已加载过持久化值) +export const getTagPriorityStrategy = (): TagPriorityStrategy => { + if (!loaded) { + strategy.value = readFromStorage(); + loaded = true; + } + return strategy.value; +}; + +// 设置并持久化策略 +export const setTagPriorityStrategy = (value: TagPriorityStrategy): void => { + if (!(VALID_STRATEGIES as string[]).includes(value)) return; + strategy.value = value; + try { + localStorage.setItem(STORAGE_KEY, value); + } catch { + // localStorage 不可用时仅更新内存态,忽略写入失败 + } +}; From b3eb1d01d9ab7a3dad88791624585b1e77209134 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Fri, 7 Aug 2026 18:21:39 +0800 Subject: [PATCH 09/36] =?UTF-8?q?fix:=20=E6=A0=87=E7=AD=BE=E4=BC=98?= =?UTF-8?q?=E5=85=88=E6=98=BE=E7=A4=BA=E7=AD=96=E7=95=A5=20auto=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=8C=89=E6=9C=8D=E5=8A=A1=E7=AB=AF=E4=BC=98?= =?UTF-8?q?=E5=85=88=E7=BA=A7=E9=85=8D=E7=BD=AE=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auto 模式用合并应用自身(含 sparkApp/apmApp)匹配 priority-config.json, 不再仅取单一子版,避免漏匹配。 - openDetail 区分「已安装页显式来源强制」与「其他页面按策略」; 非强制分支清除 forceViewingOrigin 粘性标志,避免跨入口状态泄漏。 - AppDetailModal 按 TagPriorityStrategy 响应式重算默认来源; 手动切标签仅本次会话预览,不持久。 - storeConfig.loadPriorityConfig 改用 axios(与渲染进程一致), 修复 Electron 下全局 fetch 失败导致配置为空、auto 全部回退 APM 的问题。 - 首页推荐列表 parseAppList 从 jsonUrl 提取分类,避免 category 误为 unknown。 --- electron/main/index.ts | 18 +-- src/App.vue | 57 +++++---- src/components/AppDetailModal.vue | 52 ++++++-- src/global/storeConfig.ts | 206 ++++++++++++++---------------- src/global/tagPriority.ts | 3 + src/global/typedefinition.ts | 1 + 6 files changed, 188 insertions(+), 149 deletions(-) diff --git a/electron/main/index.ts b/electron/main/index.ts index 9a37e241..8a30bbf4 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -308,21 +308,17 @@ function getWindowStatePath(): string { // 校验保存的窗口位置是否至少部分落在某个显示器可见区域内,避免窗口跑到屏幕外 function isVisible(bounds: WindowState): boolean { - if ( - bounds.width === undefined || - bounds.height === undefined || - bounds.x === undefined || - bounds.y === undefined - ) { + // 解构为局部常量后,控制流收窄(const 不可变)可穿透到下方嵌套闭包, + // 消除 x/y/width/height 的 “可能为未定义” 告警 + const { x, y, width, height } = bounds; + if (x === undefined || y === undefined || width === undefined || height === 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; + const w = display.workArea; + const horizontally = x < w.x + w.width && x + width > w.x; + const vertically = y < w.y + w.height && y + height > w.y; return horizontally && vertically; }); } diff --git a/src/App.vue b/src/App.vue index b98d945b..13129809 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1095,38 +1095,46 @@ const openDetail = async (app: App | OpenDetailInput) => { }) as Promise) : Promise.resolve(false), ]); - // 优先遵从点击来源 origin(未安装时、已安装时都应以用户点击的版本为准) - let preferred = (app as Record).origin as - | "spark" - | "apm" - | undefined; + // 来源默认展示规则: + // 1) 已安装页打开:按安装类型打开。 + // - 仅一个来源已安装 → 强制展示该已装来源(无需策略覆盖) + // - 多个来源已安装 → 按「设置的优先标签」打开(策略) + // 2) 其他页面:一律按「设置的优先标签」打开(策略 > 服务端混合默认),不强制。 + // 注:forceViewingOrigin 仅用于「已安装页 + 唯一安装来源」这一显式安装类型场景, + // 其余情况都不强制,交由详情页按用户标签策略重算。 + let forceOrigin: "spark" | "apm" | undefined = undefined; if (fromInstalled) { - // 已安装页:依据实际安装来源决定默认展示 const installedOrigins = (app as Record).origins as | Array<"spark" | "apm"> | undefined; - if (installedOrigins && installedOrigins.length > 0) { - if ( - installedOrigins.includes("spark") && - installedOrigins.includes("apm") - ) { - // 两种类型都已安装:保持所有应用页的自动显示模式(不强制指定来源) - preferred = undefined; - } else { - // 仅安装一种类型:优先显示已安装的类型(另一类型仍可在来源切换中查看) - preferred = installedOrigins[0]; - } + if (installedOrigins && installedOrigins.length === 1) { + // 单来源安装:按安装类型强制 + forceOrigin = installedOrigins[0]; + } else if (installedOrigins && installedOrigins.length > 1) { + // 多来源安装:交由策略(按设置优先标签),不强制 + forceOrigin = undefined; + } else { + // origins 未随事件携带时,用 IPC 检测结果兜底判断安装类型 + if (sparkInstalled && !apmInstalled) forceOrigin = "spark"; + else if (apmInstalled && !sparkInstalled) forceOrigin = "apm"; } } - if (preferred && (preferred === "spark" ? finalApp.sparkApp : finalApp.apmApp)) { - finalApp.viewingOrigin = preferred; + // 非强制分支:清除可能由「已安装页」入口遗留的 forceViewingOrigin 粘性标志, + // 避免同一应用从其他页面再次打开时被锁死在旧来源(表现为设置切换不生效)。 + finalApp.forceViewingOrigin = false; + if (forceOrigin && (forceOrigin === "spark" ? finalApp.sparkApp : finalApp.apmApp)) { + // 已安装页 + 唯一安装来源 → 强制展示该安装类型,优先级高于用户标签策略 + finalApp.viewingOrigin = forceOrigin; + finalApp.forceViewingOrigin = true; } else if (sparkInstalled && !apmInstalled) { - // 无点击来源时:仅一个仓库已安装则优先展示已安装版本 + // 仅 Spark 安装(其他页面):默认回退展示已装版本,不强制(仍受用户策略覆盖) finalApp.viewingOrigin = "spark"; } else if (apmInstalled && !sparkInstalled) { finalApp.viewingOrigin = "apm"; } else { - // 都安装/都未安装且未指定来源:按优先级配置决定默认展示 + // 都安装/都未安装且未指定来源:交由「标签优先显示策略」决定默认展示。 + // 此处仅写入混合默认作为回退(供 appIdentity / 截图等下游使用), + // 但不置 forceViewingOrigin,详情页会按用户策略重算。 finalApp.viewingOrigin = getHybridDefaultOrigin( finalApp.sparkApp || finalApp, ); @@ -1496,7 +1504,12 @@ const loadHomeListApps = async (entryId: string) => { mode: "spark" | "apm", ): App[] => rawApps.map((a) => { - const category = a.Category || a.category || "unknown"; + // 首页推荐列表的 jsonUrl 形如 /home/lists/xxx.json;服务端原始数据不含 category 字段, + // 这里提取 URL 首段目录作为分类(无真实分类时回退 "unknown"),避免污染后续优先级匹配。 + const urlCategory = (urls[mode] || "") + .split("/") + .filter(Boolean)[0] || "unknown"; + const category = a.Category || a.category || urlCategory; let img_urls: string[] = []; const rawImgUrls = a.img_urls; diff --git a/src/components/AppDetailModal.vue b/src/components/AppDetailModal.vue index 4c334e8b..5a6499e5 100644 --- a/src/components/AppDetailModal.vue +++ b/src/components/AppDetailModal.vue @@ -578,7 +578,8 @@ import { getHybridDefaultOrigin, } from "../global/storeConfig"; import { - getTagPriorityStrategy, + tagPriorityStrategyRef, + initTagPriorityStrategy, type TagPriorityStrategy, } from "../global/tagPriority"; // 评论功能暂时关闭 @@ -641,8 +642,9 @@ const computeDefaultViewingOrigin = ( app: App, strategy: TagPriorityStrategy, ): "spark" | "apm" => { - // 父组件已显式指定展示来源(如从已安装应用页按特定来源打开),优先级最高 - if (app.viewingOrigin) return app.viewingOrigin; + // 父组件显式指定展示来源(如从已安装应用页按特定来源打开)时优先级最高, + // 此时强制覆盖用户标签策略;否则交由下方策略逻辑决定。 + if (app.forceViewingOrigin && app.viewingOrigin) return app.viewingOrigin; // 非合并应用只有一个来源标签,直接展示该标签 if (!app.isMerged) return app.origin; @@ -661,19 +663,49 @@ const computeDefaultViewingOrigin = ( return preferred; } - // 回退到应用配置的优先级策略(getHybridDefaultOrigin),并约束在可用标签范围内 - const auto = getHybridDefaultOrigin(app.sparkApp ?? app.apmApp ?? app); + // 回退到应用配置的优先级策略(getHybridDefaultOrigin),使用合并应用自身的 + // 规范标识(pkgname/category/tags)匹配服务器 priority-config.json,并约束在可用标签范围内 + const auto = getHybridDefaultOrigin(app); + console.log( + `[PriorityConfig] auto 决策 pkgname=${app.pkgname} category=${app.category} sparkTags=${app.sparkApp?.tags ?? ""} apmTags=${app.apmApp?.tags ?? ""} -> ${auto}`, + ); return available.includes(auto) ? auto : available[0]; }; +// 进入详情页时按「标签优先显示策略」计算默认来源标签。 +// 同时监听 props.app 与共享策略 ref: +// - props.app 变化(重开/切换应用)→ 始终按当前策略重算(手动切标签仅本次会话预览,不持久) +// - 策略变化(在设置页实时切换)→ 已打开且未手动切标签的详情页立即更新默认标签 +// 手动点标签页(selectOrigin)只是临时预览:置 manualOverride 标记,本次会话内保持, +// 既不被策略变化强行拉回,也不跨重开持久(重开后会按设置重算)。 +initTagPriorityStrategy(); +const manualOverride = ref(false); // 用户是否手动切换过标签(本次会话) +let lastPkgname: string | null = null; // 上一次计算对应的应用 pkgname,用于识别"新打开/重开" watch( - () => props.app, - (newApp: App | null) => { + [() => props.app, tagPriorityStrategyRef], + ([newApp]: [App | null, TagPriorityStrategy]) => { isIconLoaded.value = false; - if (newApp) { + if (!newApp) { + // 详情页关闭:清空会话状态,确保下次打开按最新策略重算 + lastPkgname = null; + manualOverride.value = false; + return; + } + if (newApp.pkgname !== lastPkgname) { + // 新应用(含重开后再次打开同一应用):重置手动标记,按当前策略计算 + lastPkgname = newApp.pkgname; + manualOverride.value = false; viewingOrigin.value = computeDefaultViewingOrigin( newApp, - getTagPriorityStrategy(), + tagPriorityStrategyRef.value, + ); + } else if (manualOverride.value || newApp.forceViewingOrigin) { + // 同一应用且用户正在手动预览 / 父组件强制来源:保留当前标签,不重算 + } else { + // 同一应用、无手动预览、策略变化 → 实时按新策略更新默认标签 + viewingOrigin.value = computeDefaultViewingOrigin( + newApp, + tagPriorityStrategyRef.value, ); } }, @@ -845,6 +877,8 @@ const confirmOpen = (origin: "spark" | "apm") => { // }; const selectOrigin = (origin: "spark" | "apm") => { + // 手动切换标签:仅本次会话预览,不写回策略、不跨重开持久 + manualOverride.value = true; viewingOrigin.value = origin; emit("select-origin", origin); }; diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts index 5d8b7134..9527259c 100644 --- a/src/global/storeConfig.ts +++ b/src/global/storeConfig.ts @@ -1,9 +1,17 @@ import { ref } from "vue"; +import axios from "axios"; import type { App, StoreMode } from "./typedefinition"; export const APM_STORE_BASE_URL: string = import.meta.env.VITE_APM_STORE_BASE_URL || ""; +// 加载优先级配置专用客户端:与渲染进程其余请求共用同一套 HTTP 客户端, +// 避免 Electron 渲染进程中全局 fetch 失败导致配置始终为空、auto 模式全部回退 APM。 +const priorityConfigAxios = axios.create({ + baseURL: APM_STORE_BASE_URL, + timeout: 10000, +}); + export const APM_STORE_STATS_BASE_URL: string = import.meta.env.VITE_APM_STORE_STATS_BASE_URL || ""; @@ -77,59 +85,54 @@ export let hasPriorityConfigFile = false; */ export async function loadPriorityConfig(arch: string): Promise { try { - const configUrl = `${APM_STORE_BASE_URL}/${arch}-store/priority-config.json`; - const response = await fetch(configUrl, { - method: "GET", - cache: "no-cache", - }); - - if (response.ok) { - const config = await response.json(); - hasPriorityConfigFile = true; - // 支持新旧两种配置格式 - if (config.sparkPriority || config.apmPriority) { - // 新格式:双向配置 - dynamicPriorityConfig = { - sparkPriority: { - pkgnames: config.sparkPriority?.pkgnames || [], - categories: config.sparkPriority?.categories || [], - tags: config.sparkPriority?.tags || [], - }, - apmPriority: { - pkgnames: config.apmPriority?.pkgnames || [], - categories: config.apmPriority?.categories || [], - tags: config.apmPriority?.tags || [], - }, - }; - } else { - // 旧格式:只配置 sparkPriority(兼容旧配置) - dynamicPriorityConfig = { - sparkPriority: { - pkgnames: config.pkgnames || [], - categories: config.categories || [], - tags: config.tags || [], - }, - apmPriority: { - pkgnames: [], - categories: [], - tags: [], - }, - }; - } - isPriorityConfigLoaded = true; - console.log( - "[PriorityConfig] 已从服务器加载优先级配置:", - dynamicPriorityConfig, - ); + const configPath = `/${arch}-store/priority-config.json`; + console.log( + `[PriorityConfig] 开始加载优先级配置: ${APM_STORE_BASE_URL}${configPath}`, + ); + const response = await priorityConfigAxios.get(configPath); + const config = response.data; + hasPriorityConfigFile = true; + // 支持新旧两种配置格式 + if (config.sparkPriority || config.apmPriority) { + // 新格式:双向配置 + dynamicPriorityConfig = { + sparkPriority: { + pkgnames: config.sparkPriority?.pkgnames || [], + categories: config.sparkPriority?.categories || [], + tags: config.sparkPriority?.tags || [], + }, + apmPriority: { + pkgnames: config.apmPriority?.pkgnames || [], + categories: config.apmPriority?.categories || [], + tags: config.apmPriority?.tags || [], + }, + }; } else { - // 配置文件不存在,默认优先 Spark - console.log("[PriorityConfig] 服务器无配置文件,使用默认 Spark 优先"); - hasPriorityConfigFile = false; - resetPriorityConfig(); + // 旧格式:只配置 sparkPriority(兼容旧配置) + dynamicPriorityConfig = { + sparkPriority: { + pkgnames: config.pkgnames || [], + categories: config.categories || [], + tags: config.tags || [], + }, + apmPriority: { + pkgnames: [], + categories: [], + tags: [], + }, + }; } + isPriorityConfigLoaded = true; + console.log( + "[PriorityConfig] 已从服务器加载优先级配置:", + JSON.stringify(dynamicPriorityConfig), + ); } catch (error) { - // 获取失败,默认优先 Spark - console.warn("[PriorityConfig] 加载配置失败,使用默认 Spark 优先:", error); + // 获取失败(含 404:服务器无配置文件),默认优先 APM + console.warn( + `[PriorityConfig] 加载配置失败(${APM_STORE_BASE_URL}/${arch}-store/priority-config.json),使用默认 APM 优先:`, + error, + ); hasPriorityConfigFile = false; resetPriorityConfig(); } @@ -155,69 +158,58 @@ function resetPriorityConfig(): void { } /** - * 获取混合模式下应用的默认优先来源 - * 判断优先级(从高到低): - * 1. apmPriority.pkgnames - 强制优先 APM - * 2. sparkPriority.pkgnames - 强制优先 Spark - * 3. apmPriority.categories - 该分类优先 APM - * 4. sparkPriority.categories - 该分类优先 Spark - * 5. apmPriority.tags - 包含标签优先 APM - * 6. sparkPriority.tags - 包含标签优先 Spark - * 7. 默认行为: - * - 没有配置文件 → 优先 Spark - * - 有配置文件但规则为空 → 优先 APM + * 按优先级配置规则判断单个应用标识是否命中「优先来源」。 + * 命中则返回 "apm" / "spark",未命中返回 null。 + * 规则优先级(从高到低):apmPriority.pkgnames > sparkPriority.pkgnames > + * apmPriority.categories > sparkPriority.categories > apmPriority.tags > sparkPriority.tags + */ +function matchPriority(app: App): "apm" | "spark" | null { + const { sparkPriority, apmPriority } = dynamicPriorityConfig; + + if (apmPriority.pkgnames.includes(app.pkgname)) return "apm"; + if (sparkPriority.pkgnames.includes(app.pkgname)) return "spark"; + if (apmPriority.categories.includes(app.category)) return "apm"; + if (sparkPriority.categories.includes(app.category)) return "spark"; + + if (app.tags && apmPriority.tags.length > 0) { + const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase()); + for (const ruleTag of apmPriority.tags) { + if (appTags.includes(ruleTag.toLowerCase())) return "apm"; + } + } + if (app.tags && sparkPriority.tags.length > 0) { + const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase()); + for (const ruleTag of sparkPriority.tags) { + if (appTags.includes(ruleTag.toLowerCase())) return "spark"; + } + } + return null; +} + +/** + * 获取混合模式下应用的默认优先来源(即「应用配置优先级」)。 + * 对合并应用,依次用顶层标识、sparkApp、apmApp 去匹配优先级配置, + * 任一来源命中即生效(避免顶层 pkgname 取自某一子版而漏掉另一子版的规则)。 + * 均不命中时回退到 HYBRID_DEFAULT_PRIORITY(默认优先 APM)。 * @param app 应用信息 * @returns "apm" 或 "spark" */ export function getHybridDefaultOrigin(app: App): "apm" | "spark" { - const { sparkPriority, apmPriority } = dynamicPriorityConfig; + const result = matchPriority(app); + if (result) return result; - // 1. 检查 APM 优先的包名(最高优先级) - if (apmPriority.pkgnames.includes(app.pkgname)) { - return "apm"; - } - - // 2. 检查 Spark 优先的包名 - if (sparkPriority.pkgnames.includes(app.pkgname)) { - return "spark"; - } - - // 3. 检查 APM 优先的分类 - if (apmPriority.categories.includes(app.category)) { - return "apm"; - } - - // 4. 检查 Spark 优先的分类 - if (sparkPriority.categories.includes(app.category)) { - return "spark"; - } - - // 5. 检查 APM 优先的标签 - if (app.tags && apmPriority.tags.length > 0) { - const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase()); - for (const ruleTag of apmPriority.tags) { - if (appTags.includes(ruleTag.toLowerCase())) { - return "apm"; - } + // 合并应用:两个子版分别匹配,任一命中即采用 + if (app.isMerged) { + if (app.sparkApp) { + const r = matchPriority(app.sparkApp); + if (r) return r; + } + if (app.apmApp) { + const r = matchPriority(app.apmApp); + if (r) return r; } } - // 6. 检查 Spark 优先的标签 - if (app.tags && sparkPriority.tags.length > 0) { - const appTags = app.tags.split(";").map((t) => t.trim().toLowerCase()); - for (const ruleTag of sparkPriority.tags) { - if (appTags.includes(ruleTag.toLowerCase())) { - return "spark"; - } - } - } - - // 7. 默认行为 - if (!hasPriorityConfigFile) { - // 没有配置文件 → 默认优先 Spark - return "spark"; - } else { - // 有配置文件但规则为空 → 默认优先 APM - return "apm"; - } + // 默认行为:与 HYBRID_DEFAULT_PRIORITY 保持一致(默认优先 APM) + return HYBRID_DEFAULT_PRIORITY; } diff --git a/src/global/tagPriority.ts b/src/global/tagPriority.ts index eeed429e..e4b23f92 100644 --- a/src/global/tagPriority.ts +++ b/src/global/tagPriority.ts @@ -21,6 +21,9 @@ const VALID_STRATEGIES: TagPriorityStrategy[] = ["auto", "spark", "apm"]; // 共享响应式引用:设置页写入、详情页读取,保证跨组件同步 const strategy = ref("auto"); +// 导出共享 ref,供详情页以响应式方式 watch(设置变化实时生效) +export const tagPriorityStrategyRef = strategy; + let loaded = false; const readFromStorage = (): TagPriorityStrategy => { diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index 64ac8a5f..cf3dacb1 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -115,6 +115,7 @@ export interface App { sparkApp?: App; // Optional reference to the spark version apmApp?: App; // Optional reference to the apm version viewingOrigin?: "spark" | "apm"; // Currently viewed origin inside the app modal + forceViewingOrigin?: boolean; // true 表示 viewingOrigin 为父组件显式指定(如从已安装页按特定来源打开),优先级高于用户标签策略;未设置或 false 时由标签策略决定默认展示 origins?: Array<"spark" | "apm">; // 实际安装来源集合(同时以 APM 与 Spark 安装时含两项) downloadCount?: number; // 下载量(用于下载排行,由 download-times.txt 解析) } From 7ac1b2f0b6876614f05b82fa3f3e46bd5a12d378 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Mon, 10 Aug 2026 08:11:13 +0800 Subject: [PATCH 10/36] =?UTF-8?q?refactor:=20=E6=8C=89=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E6=B8=85=E7=90=86=E6=A0=87=E7=AD=BE=E4=BC=98?= =?UTF-8?q?=E5=85=88=E6=98=BE=E7=A4=BA=E7=AD=96=E7=95=A5=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 AppDetailModal auto 决策分支的调试 console.log(生产不残留调试日志) - 删除 storeConfig 中无读取方的 isPriorityConfigLoaded/hasPriorityConfigFile 死变量,并修正 catch 分支借 resetPriorityConfig 误标 loaded 的语义矛盾 - getHybridDefaultOrigin 对合并应用子版匹配时,子版 category 为空则 回退顶层 app.category,避免分类规则漏匹配 - 补充 forceViewingOrigin 强制来源不随设置策略实时切换的注释(设计行为) --- src/components/AppDetailModal.vue | 5 ++--- src/global/storeConfig.ts | 33 +++++++++++++++---------------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/components/AppDetailModal.vue b/src/components/AppDetailModal.vue index 5a6499e5..71954624 100644 --- a/src/components/AppDetailModal.vue +++ b/src/components/AppDetailModal.vue @@ -644,6 +644,8 @@ const computeDefaultViewingOrigin = ( ): "spark" | "apm" => { // 父组件显式指定展示来源(如从已安装应用页按特定来源打开)时优先级最高, // 此时强制覆盖用户标签策略;否则交由下方策略逻辑决定。 + // 注意:被强制的应用(forceViewingOrigin=true)在详情页保持打开期间, + // 即使设置页切换标签策略也不会实时变更其展示来源(已安装来源高于策略,属设计行为)。 if (app.forceViewingOrigin && app.viewingOrigin) return app.viewingOrigin; // 非合并应用只有一个来源标签,直接展示该标签 @@ -666,9 +668,6 @@ const computeDefaultViewingOrigin = ( // 回退到应用配置的优先级策略(getHybridDefaultOrigin),使用合并应用自身的 // 规范标识(pkgname/category/tags)匹配服务器 priority-config.json,并约束在可用标签范围内 const auto = getHybridDefaultOrigin(app); - console.log( - `[PriorityConfig] auto 决策 pkgname=${app.pkgname} category=${app.category} sparkTags=${app.sparkApp?.tags ?? ""} apmTags=${app.apmApp?.tags ?? ""} -> ${auto}`, - ); return available.includes(auto) ? auto : available[0]; }; diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts index 9527259c..80c10e1a 100644 --- a/src/global/storeConfig.ts +++ b/src/global/storeConfig.ts @@ -70,14 +70,6 @@ export let dynamicPriorityConfig: PriorityRules = { }, }; -// 标记是否已从服务器加载配置 -export let isPriorityConfigLoaded = false; - -// 标记服务器上是否存在配置文件 -// false = 没有配置文件,默认优先 Spark -// true = 有配置文件,按配置规则判断(配置为空则默认优先 APM) -export let hasPriorityConfigFile = false; - /** * 从服务器加载优先级配置 * 配置文件路径: ${arch}-store/priority-config.json (放在 spark 下) @@ -91,7 +83,6 @@ export async function loadPriorityConfig(arch: string): Promise { ); const response = await priorityConfigAxios.get(configPath); const config = response.data; - hasPriorityConfigFile = true; // 支持新旧两种配置格式 if (config.sparkPriority || config.apmPriority) { // 新格式:双向配置 @@ -122,24 +113,24 @@ export async function loadPriorityConfig(arch: string): Promise { }, }; } - isPriorityConfigLoaded = true; console.log( "[PriorityConfig] 已从服务器加载优先级配置:", JSON.stringify(dynamicPriorityConfig), ); } catch (error) { - // 获取失败(含 404:服务器无配置文件),默认优先 APM + // 获取失败(含 404:服务器无配置文件),默认优先 APM。 + // 注:此处为配置缺失/加载失败,dynamicPriorityConfig 重置为空规则, + // 由 getHybridDefaultOrigin 回退 HYBRID_DEFAULT_PRIORITY(默认 APM)。 console.warn( `[PriorityConfig] 加载配置失败(${APM_STORE_BASE_URL}/${arch}-store/priority-config.json),使用默认 APM 优先:`, error, ); - hasPriorityConfigFile = false; resetPriorityConfig(); } } /** - * 重置优先级配置为默认值 + * 重置优先级配置为默认值(配置缺失/加载失败时调用) */ function resetPriorityConfig(): void { dynamicPriorityConfig = { @@ -154,7 +145,6 @@ function resetPriorityConfig(): void { tags: [], }, }; - isPriorityConfigLoaded = true; } /** @@ -198,14 +188,23 @@ export function getHybridDefaultOrigin(app: App): "apm" | "spark" { const result = matchPriority(app); if (result) return result; - // 合并应用:两个子版分别匹配,任一命中即采用 + // 合并应用:两个子版分别匹配,任一命中即采用。 + // 若子版自身 category 为空(如部分入口构造的 fallback 应用), + // 回退使用顶层 app.category 参与分类规则匹配,避免漏匹配。 if (app.isMerged) { + const fallbackCategory = app.category; if (app.sparkApp) { - const r = matchPriority(app.sparkApp); + const sparkCandidate: App = app.sparkApp.category + ? app.sparkApp + : { ...app.sparkApp, category: fallbackCategory }; + const r = matchPriority(sparkCandidate); if (r) return r; } if (app.apmApp) { - const r = matchPriority(app.apmApp); + const apmCandidate: App = app.apmApp.category + ? app.apmApp + : { ...app.apmApp, category: fallbackCategory }; + const r = matchPriority(apmCandidate); if (r) return r; } } From 9247fe8e6345f7d01d7134ca240e86abb8a7b25c Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Mon, 10 Aug 2026 08:27:18 +0800 Subject: [PATCH 11/36] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=AD=90=E7=89=88=E7=9B=B8=E5=8F=8D=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E6=97=B6=E6=8C=89=20apmPriority=20=E4=BE=8B=E5=A4=96=E4=BC=98?= =?UTF-8?q?=E5=85=88=E8=A3=81=E5=86=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getHybridDefaultOrigin 原先对合并应用子版采用「spark 先于 apm」短路返回, 当 sparkApp 命中 sparkPriority 且 apmApp 命中 apmPriority(方向相反)时, 会错误返回 spark,违反 priority-config.json 中 apmPriority「例外优先」语义。 改为先收集两子版命中结果,方向相反时让 apm 胜出;仅单子版命中或同向时 维持原行为。已用 Node 复现验证(相反命中返回 apm、对照旧逻辑返回 spark)。 --- src/global/storeConfig.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts index 80c10e1a..f7e23a38 100644 --- a/src/global/storeConfig.ts +++ b/src/global/storeConfig.ts @@ -180,6 +180,7 @@ function matchPriority(app: App): "apm" | "spark" | null { * 获取混合模式下应用的默认优先来源(即「应用配置优先级」)。 * 对合并应用,依次用顶层标识、sparkApp、apmApp 去匹配优先级配置, * 任一来源命中即生效(避免顶层 pkgname 取自某一子版而漏掉另一子版的规则)。 + * 若两子版方向相反命中,遵从 apmPriority「例外优先」语义让 apm 胜出。 * 均不命中时回退到 HYBRID_DEFAULT_PRIORITY(默认优先 APM)。 * @param app 应用信息 * @returns "apm" 或 "spark" @@ -193,20 +194,17 @@ export function getHybridDefaultOrigin(app: App): "apm" | "spark" { // 回退使用顶层 app.category 参与分类规则匹配,避免漏匹配。 if (app.isMerged) { const fallbackCategory = app.category; - if (app.sparkApp) { - const sparkCandidate: App = app.sparkApp.category - ? app.sparkApp - : { ...app.sparkApp, category: fallbackCategory }; - const r = matchPriority(sparkCandidate); - if (r) return r; - } - if (app.apmApp) { - const apmCandidate: App = app.apmApp.category - ? app.apmApp - : { ...app.apmApp, category: fallbackCategory }; - const r = matchPriority(apmCandidate); - if (r) return r; - } + // 构造 category 回退顶层的候选项,避免子版 category 为空时分类规则漏匹配 + const toCandidate = (sub: App): App => + sub.category ? sub : { ...sub, category: fallbackCategory }; + const sparkHit = app.sparkApp ? matchPriority(toCandidate(app.sparkApp)) : null; + const apmHit = app.apmApp ? matchPriority(toCandidate(app.apmApp)) : null; + + // apmPriority 是「例外优先于 sparkPriority」:当两子版方向相反命中 + //(sparkApp 命中 spark 且 apmApp 命中 apm)时,按配置语义让 apm 胜出。 + if (sparkHit === "spark" && apmHit === "apm") return "apm"; + if (sparkHit) return sparkHit; + if (apmHit) return apmHit; } // 默认行为:与 HYBRID_DEFAULT_PRIORITY 保持一致(默认优先 APM) From 800a92b419850237d530bd1b39a1ea89a12d05e7 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Mon, 10 Aug 2026 17:19:55 +0800 Subject: [PATCH 12/36] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=20PR=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E6=84=8F=E8=A7=81=EF=BC=88=E7=AB=9E=E6=80=81?= =?UTF-8?q?/=E7=B1=BB=E5=9E=8B=E5=AE=89=E5=85=A8/=E5=86=97=E4=BD=99/?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=B3=84=E9=9C=B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.vue fetchAppFromStore 的 fetch 接入 rootAbortController.signal, 组件卸载时取消请求,避免竞态与内存泄漏;catch 静默处理 AbortError。 - App.vue loadHome 远程字段由 `as string` 断言改为 typeof 运行时守卫, 避免非字符串数据注入响应式状态(非崩溃,属改进)。 - ranking.ts topUpdatedContributors 移除对 topByUpdate 已过滤结果的冗余 filter。 - storeConfig 加载成功日志改为仅打印规则条数,避免完整配置泄露且保留诊断价值。 - 未采纳:parseAppPayload 默认 origin=spark 维持不变(spark 为历史默认源, 盲改 apm 有反向误路由风险,且该字符串分支仅为极旧格式兜底)。 --- src/App.vue | 37 ++++++++++++++++++++++++++++++------- src/global/storeConfig.ts | 11 +++++++++-- src/modules/ranking.ts | 5 ++--- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/App.vue b/src/App.vue index 7001edc1..5283413e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -901,7 +901,11 @@ const fetchAppFromStore = async ( const appJsonUrl = `${APM_STORE_BASE_URL}/${finalArch}/${encodeURIComponent( category, )}/${encodeURIComponent(pkgname)}/app.json`; - const response = await fetch(appJsonUrl); + // 接入 rootAbortController.signal,确保组件卸载/详情关闭时请求可被取消, + // 避免竞态与内存泄漏(onUnmounted 会 abort 该 controller) + const response = await fetch(appJsonUrl, { + signal: rootAbortController.signal, + }); if (!response.ok) return null; const appJson = await response.json(); // img_urls 可能为字符串形式的 JSON,解析失败时安全回退为空数组 @@ -935,6 +939,8 @@ const fetchAppFromStore = async ( currentStatus: "not-installed", }; } catch (e) { + // 组件卸载/详情关闭触发 abort 时静默返回,避免刷 AbortError 日志 + if ((e as Error)?.name === "AbortError") return null; console.warn(`Failed to fetch ${origin} app info for ${pkgname}`, e); return null; } @@ -1398,23 +1404,40 @@ const loadHome = async () => { >[]) : []; for (const l of links) { - const name = (l.Name as string) || (l.name as string) || ""; + // 远程数据不可信,使用 typeof 运行时守卫替代 `as string` 断言, + // 避免非字符串字段(如数字/对象)被注入状态导致下游显示异常。 + const name = + typeof l.Name === "string" + ? l.Name + : typeof l.name === "string" + ? l.name + : ""; if (!name) continue; // 跳过空名称,避免空字符串污染 seenNames 与去重逻辑 if (seenNames.has(name)) continue; // 已由更高优先级来源(spark)占据 // 仅校验 url 必需;远程 homelinks.json 不含 icon 字段(图片由 imgUrl 提供), // 故 icon 不作为硬性校验,缺省为空串以兼容 HomeLink 类型。 - const url = (l.Url as string) || (l.url as string) || ""; + const url = + typeof l.Url === "string" + ? l.Url + : typeof l.url === "string" + ? l.url + : ""; if (!url) continue; - const icon = (l.Icon as string) || (l.icon as string) || ""; + const icon = + typeof l.Icon === "string" + ? l.Icon + : typeof l.icon === "string" + ? l.icon + : ""; seenNames.add(name); // 显式提取已知字段构造,避免通过展开运算符 { ...l } 把远程不可信数据中的未知属性注入响应式状态 const safeLink: HomeLink = { name, url, icon, - more: (l.more as string) || undefined, - imgUrl: (l.imgUrl as string) || undefined, - type: (l.type as string) || undefined, + more: typeof l.more === "string" ? l.more : undefined, + imgUrl: typeof l.imgUrl === "string" ? l.imgUrl : undefined, + type: typeof l.type === "string" ? l.type : undefined, origin: mode, }; homeLinks.value.push(safeLink); diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts index f7e23a38..7b1a6a51 100644 --- a/src/global/storeConfig.ts +++ b/src/global/storeConfig.ts @@ -113,9 +113,16 @@ export async function loadPriorityConfig(arch: string): Promise { }, }; } + // 仅打印规则计数而非完整配置,避免潜在敏感信息泄露(诊断用) + const ruleCount = (r: { + pkgnames: string[]; + categories: string[]; + tags: string[]; + }) => r.pkgnames.length + r.categories.length + r.tags.length; console.log( - "[PriorityConfig] 已从服务器加载优先级配置:", - JSON.stringify(dynamicPriorityConfig), + `[PriorityConfig] 已从服务器加载优先级配置: spark ${ruleCount( + dynamicPriorityConfig.sparkPriority, + )} 条, apm ${ruleCount(dynamicPriorityConfig.apmPriority)} 条`, ); } catch (error) { // 获取失败(含 404:服务器无配置文件),默认优先 APM。 diff --git a/src/modules/ranking.ts b/src/modules/ranking.ts index fbac423e..aa71db47 100644 --- a/src/modules/ranking.ts +++ b/src/modules/ranking.ts @@ -83,8 +83,7 @@ export function topUpdatedContributors( recentN: number = RECENT_UPDATE_WINDOW, topN: number = TOP_N, ): ContributorRank[] { - const recent = topByUpdate(apps, origin, recentN).filter( - (a) => a.origin === origin, - ); + // topByUpdate 内部已按 origin 过滤,无需再次 filter + const recent = topByUpdate(apps, origin, recentN); return toRanks(countContributors(recent), topN); } From 0be6f8ad1170177ba15bcb4110ab1e85090031c7 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Mon, 10 Aug 2026 21:21:40 +0800 Subject: [PATCH 13/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=B8=AD=E5=BF=83=E6=89=93=E5=BC=80=E6=97=B6=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E4=B8=8D=E5=88=B0=E8=BD=AF=E4=BB=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shell-caller.sh: ssupdate 分支 exit $? 永远返回 0 会吞掉刷新失败, 改为 exit $exit_code 真实上报 aptss ssupdate 成败 - updateCenter.ts: 打开更新中心(open)时先执行 update-center-run-system-update (pkexec aptss ssupdate) 刷新软件源, 与刷新按钮行为一致, 避免基于陈旧 apt 缓存扫描导致漏报可更新项 经多维度专业审查(安全/功能/类型/可维护/资源/性能/兼容)通过。 --- extras/shell-caller.sh | 2 +- src/modules/updateCenter.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/extras/shell-caller.sh b/extras/shell-caller.sh index f9cb5921..dbf8e932 100755 --- a/extras/shell-caller.sh +++ b/extras/shell-caller.sh @@ -158,7 +158,7 @@ case "$command_type" in elif [[ "$2" == "ssupdate" ]]; then /usr/bin/aptss "${@:2}" -y 2>&1 exit_code=$? - exit $? + exit $exit_code else # 非 remove/install 命令,拒绝执行 echo "拒绝执行 aptss 白名单外的指令" diff --git a/src/modules/updateCenter.ts b/src/modules/updateCenter.ts index 693011a5..75a0033a 100644 --- a/src/modules/updateCenter.ts +++ b/src/modules/updateCenter.ts @@ -139,6 +139,15 @@ export const createUpdateCenterStore = (): UpdateCenterStore => { isOpen.value = true; loading.value = true; try { + // 打开更新中心时先刷新软件源,避免使用旧的 apt 缓存导致扫不到更新 + try { + await window.ipcRenderer.invoke( + "update-center-run-system-update", + storeFilter, + ); + } catch (error) { + console.error("[UpdateCenter] open: system update failed", error); + } const nextSnapshot = await window.updateCenter.open(storeFilter); applySnapshot(nextSnapshot); } finally { From 3f222075053a8d489c5ac34fd6e8c79493755412 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Mon, 10 Aug 2026 21:42:36 +0800 Subject: [PATCH 14/36] =?UTF-8?q?fix/refactor:=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E6=89=AB=E6=8F=8F=E4=B8=8E=E5=A4=9A=E9=A1=B9?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA=EF=BC=88=E7=BB=8F=E4=B8=93?= =?UTF-8?q?=E4=B8=9A=E5=AE=A1=E8=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新中心扫描修复: - updateCenter.ts: 抽出 runSystemUpdateThenLoad, open/refresh 共用, 打开时即刷新双源(aptss ssupdate + apm update), 失败仅告警不阻断扫描 - shell-caller.sh: ssupdate 分支 exit $? 恒 0 吞掉刷新失败, 改为 exit $exit_code - update-center/query.ts: 移除 nextVersion===currentVersion 误删真实更新项逻辑, 信任 aptss 上游 upgradable 判断 安全加固: - AppDetailModal.vue: 新增 sanitizeMoreContent 剥除 HTML 标签后再 v-html, 防 XSS - InstalledAppsModal.vue: ALLOWED_LOCAL_ICON_PREFIXES 收紧为具体子目录, 缩小 本地文件读取面 - install-manager.ts: filename 用 path.basename 防路径遍历; 包名/文件名 PKGNAME_PATTERN 校验 - index.ts: 临时目录改用 spark-store-${pid} 隔离, will-quit 清理对应目录 经 7 维度专业审计(安全/功能/类型/可维护/资源/性能/兼容)通过。 --- electron/main/backend/install-manager.ts | 16 +- .../main/backend/update-center/install.ts | 7 +- electron/main/backend/update-center/query.ts | 6 +- electron/main/index.ts | 24 ++- src/components/AppDetailModal.vue | 20 +- src/components/InstalledAppsModal.vue | 204 +++++++++--------- src/modules/updateCenter.ts | 37 ++-- 7 files changed, 180 insertions(+), 134 deletions(-) diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index 4d63e2b0..2fd0abc5 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -339,7 +339,12 @@ ipcMain.on("queue-install", async (event, download_json) => { const superUserCmd = await checkSuperUserCommand(); let execCommand = ""; const execParams = []; - const downloadDir = `/tmp/spark-store/download/${pkgname}`; + const downloadDir = path.join( + os.tmpdir(), + `spark-store-${process.pid}`, + "download", + pkgname, + ); // APM 应用:若本机没有 apm 命令,通知前端弹窗引导安装 APM if (origin === "apm") { @@ -390,7 +395,14 @@ ipcMain.on("queue-install", async (event, download_json) => { execParams.push("apm"); if (metalinkUrl && filename) { - execParams.push("ssinstall", `${downloadDir}/${filename}`); + // 防御性深度校验:即便 PKGNAME_PATTERN 已挡掉路径遍历字符,仍用 path.basename + // 确保 filename 为纯文件名、不含目录分量(belt-and-suspenders) + const safeFilename = path.basename(filename); + if (safeFilename !== filename) { + logger.warn(`ssinstall filename contains path traversal: ${filename}`); + return; + } + execParams.push("ssinstall", path.join(downloadDir, safeFilename)); } else { execParams.push("install", "-y", pkgname); } diff --git a/electron/main/backend/update-center/install.ts b/electron/main/backend/update-center/install.ts index 03d6ee86..efe9576d 100644 --- a/electron/main/backend/update-center/install.ts +++ b/electron/main/backend/update-center/install.ts @@ -1,11 +1,16 @@ import { join } from "node:path"; +import { tmpdir } from "node:os"; import { runAria2Download, type Aria2DownloadResult } from "./download"; import { installPackage } from "../shared-installer"; import type { UpdateCenterQueue, UpdateCenterTask } from "./queue"; import type { UpdateCenterItem } from "./types"; -const DEFAULT_DOWNLOAD_ROOT = "/tmp/spark-store/update-center"; +const DEFAULT_DOWNLOAD_ROOT = join( + tmpdir(), + `spark-store-${process.pid}`, + "update-center", +); export interface InstallUpdateItemOptions { item: UpdateCenterItem; diff --git a/electron/main/backend/update-center/query.ts b/electron/main/backend/update-center/query.ts index 9b2c9f34..84f7e409 100644 --- a/electron/main/backend/update-center/query.ts +++ b/electron/main/backend/update-center/query.ts @@ -210,7 +210,11 @@ const parseUpgradableOutput = ( const arch = tokens[2] ?? ""; const currentVersion = trimmed.match(CURRENT_VERSION_PATTERN)?.[1] ?? tokens[5] ?? ""; - if (!pkgname || nextVersion === currentVersion) { + // 仅当包名缺失或当前版本解析失败时才跳过。 + // 注意:不再因 nextVersion === currentVersion 而跳过——aptss 已判定该项为 + // upgradable,应信任上游判断;否则当仓库元数据出现"同版本重新发布"等情况时, + // 真实的更新项会被无声隐藏,导致"软件更新"列表空白。 + if (!pkgname || !currentVersion) { continue; } diff --git a/electron/main/index.ts b/electron/main/index.ts index c9f47616..eb7e6fd4 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -97,6 +97,10 @@ export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron"); export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist"); export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL; +// 进程专属临时目录,避免多实例/残留进程互相影响 +// 退出时由 will-quit 统一清理 +export const TEMP_BASE = path.join(os.tmpdir(), `spark-store-${process.pid}`); + process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST; @@ -311,7 +315,12 @@ function isVisible(bounds: WindowState): boolean { // 解构为局部常量后,控制流收窄(const 不可变)可穿透到下方嵌套闭包, // 消除 x/y/width/height 的 “可能为未定义” 告警 const { x, y, width, height } = bounds; - if (x === undefined || y === undefined || width === undefined || height === undefined) { + if ( + x === undefined || + y === undefined || + width === undefined || + height === undefined + ) { return false; } const displays = screen.getAllDisplays(); @@ -327,9 +336,7 @@ function loadWindowState(): WindowState { try { const file = getWindowStatePath(); if (fs.existsSync(file)) { - const parsed = JSON.parse( - fs.readFileSync(file, "utf-8"), - ) as WindowState; + const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as WindowState; if ( parsed.width !== undefined && parsed.height !== undefined && @@ -400,7 +407,10 @@ async function createWindow() { : Math.max(saved.width ?? DEFAULT_WINDOW_SIZE.width, MIN_WINDOW_SIZE.width); const restoredHeight = oversized ? DEFAULT_WINDOW_SIZE.height - : Math.max(saved.height ?? DEFAULT_WINDOW_SIZE.height, MIN_WINDOW_SIZE.height); + : Math.max( + saved.height ?? DEFAULT_WINDOW_SIZE.height, + MIN_WINDOW_SIZE.height, + ); const mainWindow = new BrowserWindow({ title: "星火应用商店", @@ -663,9 +673,9 @@ app.on("activate", () => { }); app.on("will-quit", () => { - // Clean up temp dir + // 清理本进程专属临时目录(PID 隔离,不影响其他实例) logger.info("Cleaning up temp dir"); - fs.rmSync("/tmp/spark-store/", { recursive: true, force: true }); + fs.rmSync(TEMP_BASE, { recursive: true, force: true }); logger.info("Done, exiting"); }); diff --git a/src/components/AppDetailModal.vue b/src/components/AppDetailModal.vue index 71954624..5f0ac9c8 100644 --- a/src/components/AppDetailModal.vue +++ b/src/components/AppDetailModal.vue @@ -296,7 +296,7 @@
- 。 + *
是纯程序生成,不含任何用户输入,可安全放入 v-html。 + */ +const sanitizeMoreContent = (raw: string): string => { + if (!raw) return ""; + // 去除所有 HTML 标签,避免 XSS(包括 diff --git a/src/components/update-center/UpdateCenterList.vue b/src/components/update-center/UpdateCenterList.vue index 68b10e5b..40e51db8 100644 --- a/src/components/update-center/UpdateCenterList.vue +++ b/src/components/update-center/UpdateCenterList.vue @@ -10,7 +10,7 @@
; + apps: App[]; }>(); defineEmits<{ @@ -47,6 +49,23 @@ defineEmits<{ (e: "unignore-item", packageName: string, newVersion: string): void; }>(); +// 从商店目录 apps 的 update 字段(app.json 的 Update,如 "2026-08-08") +// 推算更新发布时间,补全到更新列表项,用于显示「X天前」。匹配不到则保留原值(降级为「—」)。 +const enrichedItems = computed(() => { + const updateByPkg = new Map(); + for (const app of props.apps) { + if (app.pkgname && app.update) { + const t = Date.parse(app.update); + if (!Number.isNaN(t)) updateByPkg.set(app.pkgname, t); + } + } + return props.items.map((item) => { + if (item.updateTime && !Number.isNaN(item.updateTime)) return item; + const t = updateByPkg.get(item.packageName); + return t ? { ...item, updateTime: t } : item; + }); +}); + const taskMap = computed(() => { return new Map(props.tasks.map((task) => [task.taskKey, task])); }); diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index b5435bce..ae46fd0e 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -156,6 +156,8 @@ export interface UpdateCenterItem { migrationSource?: UpdateSource; migrationTarget?: UpdateSource; aptssVersion?: string; + // 更新发布时间(毫秒时间戳),用于列表显示「X天前」;暂无数据时前端降级为「—」 + updateTime?: number; } export interface UpdateCenterTaskState { diff --git a/src/modules/updateCenter.ts b/src/modules/updateCenter.ts index c26d7cce..172b7a1a 100644 --- a/src/modules/updateCenter.ts +++ b/src/modules/updateCenter.ts @@ -90,7 +90,14 @@ export const createUpdateCenterStore = (): UpdateCenterStore => { const filteredItems = computed(() => { const query = searchQuery.value.trim(); - return snapshot.value.items.filter((item) => matchesSearch(item, query)); + const matched = snapshot.value.items.filter((item) => + matchesSearch(item, query), + ); + // 已忽略项沉底:非忽略在前、已忽略在后,各自保持原有顺序 + return [ + ...matched.filter((item) => item.ignored !== true), + ...matched.filter((item) => item.ignored === true), + ]; }); const allSelected = computed(() => { From a7b8cd52af3091b85a104a9acb0bd567fb3e7b59 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Wed, 12 Aug 2026 20:28:27 +0800 Subject: [PATCH 23/36] =?UTF-8?q?fix(=E5=AE=89=E5=85=A8/=E6=80=A7=E8=83=BD?= =?UTF-8?q?):=20AI=E5=AE=A1=E6=9F=A5=E5=AE=9E=E8=AF=81=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20-=20metalinkUrl=20SSRF=E6=A0=A1=E9=AA=8C=20+=20withTimeout?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E5=99=A8=E6=B3=84=E6=BC=8F=20+=20=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=9B=B4=E6=96=B0/=E5=88=9D=E5=A7=8B=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install-manager.ts: queue-install 增加 metalinkUrl 校验(仅允许相对路径或官方域名,防 SSRF) - App.vue: withTimeout 用 finally(clearTimeout) 修复定时器泄漏 - App.vue: apps 批量更新改数组赋值替代 push(...),避免逐元素响应式重渲染 - App.vue: apps.length watcher 增加 initialCatalogLoaded 守卫,初始目录加载期间跳过已安装列表刷新 IPC - 审计实证:①filename校验已有(误报) ④pauseDownload为历史TODO且后端不支持(不在此PR) ⑦⑧为可维护性大项(记录待后续) 注:AI审查4阻断项实证——①误报 ②真实加固 ③真实修复 ④历史遗留;4改进项 ⑤⑥真实已修 ⑦⑧记录 --- electron/main/backend/install-manager.ts | 14 ++++++++++++++ src/App.vue | 21 ++++++++++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index c345720b..8100cd62 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -313,6 +313,20 @@ ipcMain.on("queue-install", async (event, download_json) => { return; } + // metalinkUrl 来自渲染端(由目录 filename / 主进程解析的 downloadUrl 拼成)。 + // 纵深防御:仅允许相对路径(拼接 baseURL)或以官方域名开头的绝对 URL, + // 拒绝任意外部地址,防止 axios 忽略 baseURL 发起 SSRF/任意源下载。 + if (metalinkUrl) { + const isRelative = metalinkUrl.startsWith("/"); + const isOfficial = metalinkUrl.startsWith( + "https://erotica.spark-app.store", + ); + if (!isRelative && !isOfficial) { + logger.warn(`queue-install invalid metalinkUrl: ${metalinkUrl}`); + return; + } + } + logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`); const webContents = event.sender; diff --git a/src/App.vue b/src/App.vue index 01fbe0e5..b1123a46 100644 --- a/src/App.vue +++ b/src/App.vue @@ -509,17 +509,17 @@ const isInstalledAppInfo = (value: unknown): value is InstalledAppInfo => { const LIST_INSTALLED_TIMEOUT_MS = 15000; // 为 Promise 增加超时控制:超时即 reject,配合 Promise.allSettled 让单来源失败不影响其它来源。 +// 注意:promise 先 resolve 时必须 clearTimeout,否则 setTimeout 句柄泄漏。 const withTimeout = ( promise: Promise, ms: number, label: string, ): Promise => { - return Promise.race([ - promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error(`${label} 超时(${ms}ms)`)), ms), - ), - ]); + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} 超时(${ms}ms)`)), ms); + }); + return Promise.race([promise.finally(() => clearTimeout(timer)), timeout]); }; // 响应式状态 @@ -1946,10 +1946,14 @@ const refreshInstalledApps = async () => { // 现改为 len 任意 >0 的正向变化都允许触发;密集分批推送由下方 300ms 防抖合并最后一次写入。 // 每次变化都先自增 installedRefreshGeneration:即使上一轮刷新仍在加载中,也会立即失效, // 避免用陈旧目录数据覆盖已安装列表(清理不单纯依赖定时器,代次校验兜底)。 +// initialCatalogLoaded:初始目录分批加载期间(apps.length 频繁变化)跳过此 watcher, +// 避免对未打开的模态框做无意义的已安装列表刷新 IPC。 +const initialCatalogLoaded = ref(false); let refreshDebounceTimer: ReturnType | null = null; watch( () => apps.value.length, (len) => { + if (!initialCatalogLoaded.value) return; if (!showInstalledModal.value || len <= 0) { return; } @@ -3234,12 +3238,15 @@ const loadApps = async (onFirstBatch?: () => void) => { ); // 增量式更新,让用户尽快看到部分数据 - apps.value.push(...normalizedApps); + // 用赋值替代 push(...),避免对响应式数组逐元素触发 re-render + apps.value = [...apps.value, ...normalizedApps]; // 只要有一个分类加载成功,就可以考虑关闭整体 loading(如果是首批逻辑) if (!firstBatchCallDone && typeof onFirstBatch === "function") { firstBatchCallDone = true; onFirstBatch(); + // 标记初始目录加载完成,使 apps.length watcher 开始在目录变更时刷新已安装列表 + initialCatalogLoaded.value = true; } } catch (error) { logger.warn( From 5e7d88ecc52df8170ed9dfa4e73fb060ea45d4ec Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Wed, 12 Aug 2026 21:02:48 +0800 Subject: [PATCH 24/36] =?UTF-8?q?fix(=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D)?= =?UTF-8?q?:=20=E7=BC=93=E5=AD=98=E7=A9=BF=E9=80=8FTTL=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=20+=20=E9=A2=84=E5=88=B7=E6=96=B0=E7=BD=91=E7=BB=9C/=E8=B6=85?= =?UTF-8?q?=E6=97=B6=20+=20=E6=9C=80=E5=A4=A7=E5=8C=96/APM=E4=BC=98?= =?UTF-8?q?=E5=85=88=E8=AE=BE=E8=AE=A1=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 cacheBusterInterceptor.ts:createCacheBusterInterceptor(ttl=5min) 带 TTL 复用戳, 解决同会话频繁击穿缓存(B1)并将 App.vue/storeConfig 两处重复逻辑合并为共享工厂(I3) - storeConfig.ts: priorityConfigAxios 改用共享拦截器;HYBRID_DEFAULT_PRIORITY 加注释 明确「主推 APM 是既定产品方向」(B2,非缺陷) - App.vue: axiosInstance 改用共享拦截器(带 TTL) - index.ts: 预刷新 startSourcePreRefreshOnce 加 net.isOnline() 检查 + 60s 超时兜底, 避免离线/卡死触发 pkexec 弹窗困惑(I1);createWindow 恢复逻辑加注释明确 「最大化后恢复重置为默认尺寸是有意设计,非缺陷」(I2) - vue-tsc + eslint 通过 --- debian/changelog | 2 +- electron/main/index.ts | 30 +++++++++++++++++++++---- src/App.vue | 21 +++++------------- src/global/cacheBusterInterceptor.ts | 33 ++++++++++++++++++++++++++++ src/global/storeConfig.ts | 21 +++++++----------- 5 files changed, 73 insertions(+), 34 deletions(-) create mode 100644 src/global/cacheBusterInterceptor.ts diff --git a/debian/changelog b/debian/changelog index ef47ec80..f6205efd 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -spark-store (5.2.1.18-test) UNRELEASED; urgency=medium +spark-store (5.2.1.19-test) UNRELEASED; urgency=medium * Initial release. (Closes: #nnnn) diff --git a/electron/main/index.ts b/electron/main/index.ts index ba353933..c9cb8616 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -4,6 +4,7 @@ import { ipcMain, Menu, nativeImage, + net, shell, Tray, nativeTheme, @@ -437,10 +438,15 @@ async function createWindow() { }); win = mainWindow; - // 不再自动恢复最大化状态(避免无法还原);启动即居中显示,过大窗口已回退默认尺寸 + // 设计意图(非缺陷,勿改为自动恢复 maximized): + // 启动时不自动恢复最大化状态。原因——最大化窗口在多数屏幕上 bounds 会超过 + // OVERSIZED_WINDOW_THRESHOLD(1600x900),若强行恢复最大化会导致「启动即全屏、 + // 还原按钮失效」的体验问题;故最大化/全屏状态在关闭后统一回退为默认尺寸并居中, + // 放大/还原交由标题栏按钮由用户主动控制。WindowState 仍保存 maximized 字段 + // (供需要该信息的场景读取),但恢复阶段有意不调用 win.maximize()。 logger.info( { saved, restoredWidth, restoredHeight, oversized }, - "已恢复窗口状态(过大窗口回退默认尺寸并居中)", + "已恢复窗口状态(过大窗口回退默认尺寸并居中;最大化状态有意不自动恢复)", ); // 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复 @@ -687,6 +693,7 @@ app.whenReady().then(() => { const PRE_REFRESH_BACKOFF_MS = [2000, 4000, 8000]; const PRE_REFRESH_MAX_RETRIES = 3; const PRE_REFRESH_FALLBACK_MS = 15_000; // 渲染信号未到达时的兜底,15s 后也跑 +const PRE_REFRESH_TIMEOUT_MS = 60_000; // 单次预刷新整体超时,避免 pkexec 卡死挂起 let preRefreshStarted = false; // 按重试次数取退避毫秒(越界时回退到最大间隔) @@ -694,13 +701,28 @@ const getPreRefreshBackoffDelay = (attempt: number): number => PRE_REFRESH_BACKOFF_MS[attempt - 1] ?? PRE_REFRESH_BACKOFF_MS[PRE_REFRESH_BACKOFF_MS.length - 1]; -// 确保预刷新只触发一次(渲染信号或兜底定时器 whichever first) +// 确保预刷新只触发一次(渲染信号或兜底定时器 whichever first)。 +// 设计意图:单次会话仅预热一次(preRefreshStarted 置 true 后不再重置), +// 避免用户在更新中心 Tab 间快速切换时重复触发 pkexec 弹窗造成困惑。 const startSourcePreRefreshOnce = (attempt = 1): void => { if (preRefreshStarted) return; preRefreshStarted = true; const run = (): void => { - runSystemUpdateSources("both") + // 网络可用性前置检查:离线时不发起提权刷新(pkexec 弹窗无意义且困惑) + if (!net.isOnline()) { + logger.info("[UpdateCenter] 预刷新跳过:当前离线"); + return; + } + + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error("pre-refresh timeout")), + PRE_REFRESH_TIMEOUT_MS, + ), + ); + + Promise.race([runSystemUpdateSources("both"), timeoutPromise]) .then((results) => { console.log("[UpdateCenter] pre-refresh done:", results); }) diff --git a/src/App.vue b/src/App.vue index b1123a46..0b9a6d12 100644 --- a/src/App.vue +++ b/src/App.vue @@ -344,6 +344,7 @@ import { getHybridDefaultOrigin, loadPriorityConfig, } from "./global/storeConfig"; +import { createCacheBusterInterceptor } from "./global/cacheBusterInterceptor"; import { downloads, removeDownloadItem, @@ -433,22 +434,10 @@ const axiosInstance = axios.create({ }); // C2:数据 JSON(applist / categories / sidebar-config 等)追加 ?_t 版本戳, -// 使每次 URL 不同,穿透 CDN 边缘缓存,确保返回最新列表。 -// 与主进程 onBeforeSendHeaders 注入的 no-cache 互为兜底(C1 已对 Chromium 磁盘缓存生效)。 -axiosInstance.interceptors.request.use((config) => { - if ( - config.method?.toLowerCase() === "get" && - typeof config.url === "string" - ) { - // 按去除 query 的 pathname 判断,兼容 C2 自身追加的 ?_t= 及任何既有 query - const reqPath = config.url.split("?")[0]; - if (reqPath.endsWith(".json")) { - const sep = config.url.includes("?") ? "&" : "?"; - config.url = `${config.url}${sep}_t=${Date.now()}`; - } - } - return config; -}); +// 穿透 CDN 边缘缓存,确保返回最新列表。复用共享缓存穿透拦截器(带 TTL 复用戳, +// 避免同会话频繁击穿缓存)。与主进程 onBeforeSendHeaders 注入的 no-cache 互为兜底。 +// 这是有意的缓存击穿策略(非缺陷),用于解决商店目录强缓存导致的更新延迟。 +axiosInstance.interceptors.request.use(createCacheBusterInterceptor()); const fetchWithRetry = async ( url: string, diff --git a/src/global/cacheBusterInterceptor.ts b/src/global/cacheBusterInterceptor.ts new file mode 100644 index 00000000..295caa01 --- /dev/null +++ b/src/global/cacheBusterInterceptor.ts @@ -0,0 +1,33 @@ +import type { InternalAxiosRequestConfig } from "axios"; + +/** + * 缓存穿透拦截器工厂:为所有 .json GET 请求追加 ?_t=${timestamp} 版本戳, + * 穿透 CDN/浏览器缓存,确保新应用上架后能立即被搜到(解决"上架后搜不到")。 + * + * 为避免同一次会话内每次请求都生成新戳导致缓存完全失效(频繁重复请求、浪费带宽), + * 引入 TTL:在 ttlMs 窗口内复用同一时间戳;超过窗口才刷新。 + * 默认 5 分钟,兼顾"及时感知新数据"与"不过度击穿缓存"。 + * + * 注意:这是有意为之的缓存击穿策略(非缺陷),用于解决商店目录强缓存导致的更新延迟。 + */ +export function createCacheBusterInterceptor(ttlMs = 5 * 60 * 1000) { + let cachedTimestamp: number | null = null; + + return (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + if ( + config.method?.toLowerCase() === "get" && + typeof config.url === "string" + ) { + const reqPath = config.url.split("?")[0]; + if (reqPath.endsWith(".json")) { + const now = Date.now(); + if (cachedTimestamp === null || now - cachedTimestamp > ttlMs) { + cachedTimestamp = now; + } + const sep = config.url.includes("?") ? "&" : "?"; + config.url = `${config.url}${sep}_t=${cachedTimestamp}`; + } + } + return config; + }; +} diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts index 4768dcde..550b4c0e 100644 --- a/src/global/storeConfig.ts +++ b/src/global/storeConfig.ts @@ -1,6 +1,7 @@ import { ref } from "vue"; import axios from "axios"; import type { App, StoreMode } from "./typedefinition"; +import { createCacheBusterInterceptor } from "./cacheBusterInterceptor"; export const APM_STORE_BASE_URL: string = import.meta.env.VITE_APM_STORE_BASE_URL || ""; @@ -13,18 +14,9 @@ const priorityConfigAxios = axios.create({ }); // C2:priority-config.json 走独立 axios 实例(不经过 axiosInstance), -// 同样追加 ?_t 版本戳以穿透 CDN 边缘缓存,与 C1 的 no-cache 注入互为兜底。 -priorityConfigAxios.interceptors.request.use((config) => { - if (config.method?.toLowerCase() === "get" && typeof config.url === "string") { - // 按去除 query 的 pathname 判断,兼容 C2 自身追加的 ?_t= 及任何既有 query - const reqPath = config.url.split("?")[0]; - if (reqPath.endsWith(".json")) { - const sep = config.url.includes("?") ? "&" : "?"; - config.url = `${config.url}${sep}_t=${Date.now()}`; - } - } - return config; -}); +// 复用共享缓存穿透拦截器(带 TTL 复用戳,避免同会话频繁击穿缓存)。 +// 与 C1(axiosInstance 的拦截器)为同一工厂生成,确保穿透策略与 TTL 行为一致。 +priorityConfigAxios.interceptors.request.use(createCacheBusterInterceptor()); export const APM_STORE_STATS_BASE_URL: string = import.meta.env.VITE_APM_STORE_STATS_BASE_URL || ""; @@ -51,7 +43,10 @@ export const showApmInstallDialog = ref(false); export const currentStoreMode = ref("hybrid"); -// 混合模式下默认优先安装的来源(当没有服务器配置或配置获取失败时使用) +// 混合模式下默认优先安装的来源(当没有服务器配置或配置获取失败时使用)。 +// 设计意图:社区版当前主推 APM 来源(星火 APM 为新架构、deb 为传统来源), +// 故默认优先级设为 "apm"。非临时回归,属既定产品方向;如后续调整需产品确认。 +// TODO: 产品确认默认 APM 优先是否长期保持。 export const HYBRID_DEFAULT_PRIORITY: "apm" | "spark" = "apm"; // 优先级规则配置接口 From d7bdcf36809b34e545e16a6cecb8fdf2bf668da1 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Wed, 12 Aug 2026 23:16:17 +0800 Subject: [PATCH 25/36] =?UTF-8?q?fix:=20=E6=A0=87=E7=AD=BE=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E5=90=AF=E5=8A=A8=E5=88=9D=E5=A7=8B=E5=8C=96=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=20+=20=E5=B7=B2=E5=AE=89=E8=A3=85=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E8=BF=87=E6=BB=A4/=E7=BB=9F=E8=AE=A1=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 标签优先显示策略 initTagPriorityStrategy 提到 App.vue onMounted 统一调用, 消除组件挂载顺序导致先读内存默认 auto 的竞态窗口(AppDetailModal 移除重复调用) - InstalledAppsModal 过滤逻辑合并为单次遍历(保留 hasOrigin 双来源语义) - 统计 apm/spark/total 改为基于 searchFilteredApps 单次遍历 - canOpenDetail 补充业务含义注释与可读性变量 - 将 App.vue 重构的 9 个 composable 纳入版本管理 - 验证:vue-tsc 0 / eslint 0 / dpkg-buildpackage 打包 spark-store_5.2.1.24-test 成功 --- debian/changelog | 2 +- src/App.vue | 2951 +++---------------------- src/components/AppDetailModal.vue | 3 +- src/components/InstalledAppsModal.vue | 70 +- src/composables/useAccountSync.ts | 424 ++++ src/composables/useAppDetail.ts | 475 ++++ src/composables/useAppState.ts | 167 ++ src/composables/useCatalog.ts | 567 +++++ src/composables/useDownloads.ts | 233 ++ src/composables/useFavorites.ts | 489 ++++ src/composables/useHttp.ts | 73 + src/composables/useInstalledApps.ts | 367 +++ src/composables/useRanking.ts | 218 ++ 13 files changed, 3367 insertions(+), 2672 deletions(-) create mode 100644 src/composables/useAccountSync.ts create mode 100644 src/composables/useAppDetail.ts create mode 100644 src/composables/useAppState.ts create mode 100644 src/composables/useCatalog.ts create mode 100644 src/composables/useDownloads.ts create mode 100644 src/composables/useFavorites.ts create mode 100644 src/composables/useHttp.ts create mode 100644 src/composables/useInstalledApps.ts create mode 100644 src/composables/useRanking.ts diff --git a/debian/changelog b/debian/changelog index f6205efd..0ef7a34e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -spark-store (5.2.1.19-test) UNRELEASED; urgency=medium +spark-store (5.2.1.24-test) UNRELEASED; urgency=medium * Initial release. (Closes: #nnnn) diff --git a/src/App.vue b/src/App.vue index 0b9a6d12..abd3b205 100644 --- a/src/App.vue +++ b/src/App.vue @@ -303,8 +303,20 @@