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/debian/changelog b/debian/changelog index 9b26350d..b86c8b29 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -spark-store (5.2.1.0) UNRELEASED; urgency=medium +spark-store (5.2.1.9-test) UNRELEASED; urgency=medium * Initial release. (Closes: #nnnn) diff --git a/docs/Manually-recorded-development-plan.txt b/docs/Manually-recorded-development-plan.txt new file mode 100644 index 00000000..47849636 --- /dev/null +++ b/docs/Manually-recorded-development-plan.txt @@ -0,0 +1,2 @@ +1、将标题栏变宽一点,现在加入了搜索栏,太窄了不好看 +2、在贡献排行中,点击对应的贡献用户,可以查看用户下所有的应用(可能需要模糊查询) \ No newline at end of file diff --git a/docs/app-list-cache-analysis.md b/docs/app-list-cache-analysis.md new file mode 100644 index 00000000..dab25c41 --- /dev/null +++ b/docs/app-list-cache-analysis.md @@ -0,0 +1,268 @@ +# 应用列表缓存导致"新应用上架后搜不到"的根因分析与设计方案 + +> App List Cache Analysis & Design (spark-store) +> 整理日期:2026-08-11 +> 背景分支:`Erotica` +> 文档涵盖:① 列表缓存根因与修复(C1/C2);② 下载日志断层修复(附加) + +--- + +## 0. 现象(用户反馈) + +- 服务端上新应用后,在商店内**搜不到**该应用。 +- 直觉认为是 `~/.cache` 下的本地缓存导致,但实测并非如此。 +- 进一步反馈:**重启应用也无效,只有"删除缓存"才行**——这指向缓存层在重启后仍返回陈旧数据。 + +--- + +## 1. 缓存层逐层排查 + +| 缓存层 | 是否存在 | 证据 | 是否导致本问题 | +|---|---|---|---| +| **① 内存(渲染进程 `apps` 数组)** | 是 | 搜索基于内存 `baseApps`(`App.vue:665`);`loadApps` 仅在 `onMounted` 调用一次(`App.vue:3447`) | 是(叠加因素):运行期不刷新,新应用不在内存 | +| **② 本地磁盘"应用列表"文件缓存** | **否** | 源码无 `writeFile(applist)`;`~/.config/spark-store/` 下无应用列表缓存文件 | 否(用户最初怀疑点,已排除) | +| **③ Chromium 网络磁盘缓存** | **是(关键)** | 渲染进程 `axios` 走 XHR,受 Chromium 网络栈影响;`~/.config/spark-store/Cache` 目录存在 | **是**:重启后请求命中该磁盘缓存,返回旧 `applist.json` | +| **④ CDN 边缘缓存** | **是(关键)** | 生产域名 `erotica.spark-app.store` 挂 CDN;静态 `applist.json` 被边缘缓存;代码未发 `no-cache` | **是**:即使穿透③,CDN 仍可能返回边缘陈旧副本 | + +### 请求侧缓存控制现状(代码事实) +- `fetchWithRetry`(`App.vue:432`):`axiosInstance.get(url, { signal })`,**无任何 `Cache-Control` 头**。 +- `axiosInstance`(`App.vue:427`):仅 `baseURL` + `timeout`,无 headers。 +- 主进程 `onBeforeSendHeaders`(`electron/main/index.ts:653`):只注入 `User-Agent`,**未注入 `Cache-Control: no-cache`**。 +- `loadApps`(`App.vue:3216`):`apps.value.push(...)` —— **append 模式、无去重、无重置**,重跑会累积重复。 +- `loadTabApps`(`App.vue:3098`):`if (tabApps.value[entryId]) return;` —— **已加载分类强缓存,永不重拉**。 +- `loadCategories`(`App.vue:3430`):`categories.json` 仅启动时加载一次;若为"新分类"则永不加载其 `applist.json`。 + +--- + +## 2. 根因结论(实测修正) + +> **2026-08-11 实测修正**:曾假设"CDN 边缘主动缓存",curl 实测远程 `applist.json` 响应头为 `server: nginx` + `etag` + `last-modified`,**无任何 `Cache-Control`/`Expires`/`Age`**。因此真因是 **nginx 未发缓存控制头 → Chromium 启发式缓存(heuristic caching)**,而非 CDN 主动边缘缓存。 + +**主因(解释"重启无效、删除才行")—— Chromium 启发式缓存:** +> 对"无 Cache-Control 头"的响应,Chromium 按 `Last-Modified` 计算启发式 TTL ≈ `(now - LM)/10`。实测当前文件 `LM` 距今约 7.6h → TTL ≈ **46 分钟**。在此窗口内: +> - 重启应用 → 重新发请求 → Chromium 直接复用 `~/.config/spark-store/Cache` 旧副本(启发式视为"新鲜")→ 返回**旧 JSON** → 新应用搜不到。 +> - 只有**删除 `~/.config/spark-store/Cache`** 强制失效,才可能拿到新内容。 +> 这与"重启无效、删缓存才行"的现象**精确吻合**。 + +**叠加因素(解释"运行期搜不到"):** +> `loadApps` 仅在启动时加载一次、且 `loadTabApps` 对已加载分类强缓存,运行期内存数据不会自动更新。即便请求能穿透缓存,也需重启/手动触发才进入内存。 + +**次要因素(条件触发):** +> 若新应用落入 `categories.json` 尚未包含的**新分类**,`loadApps` 遍历 `categories.value` 不会请求该分类,连重启都搜不到,需等 `categories.json` 同步。 + +> 说明:用户最初怀疑的"~/.cache 应用列表文件缓存"**不存在**;真因是 nginx 无 CC 头导致的 Chromium 启发式缓存(③层的精确机理),其陈旧表现与"文件缓存"一致,故被误判。 + +### 2.1 实测证据(curl / Node,2026-08-11) +- `curl -sI` 远程 `applist.json`:`HTTP/2 200`、`server: nginx`、`etag: "6a7a764e-2f9d8"`、`last-modified: Tue, 11 Aug 2026 01:09:34 GMT`,**无 `cache-control`/`expires`/`age`**。 +- Node 计算:LM 距今 ≈ 27572s → 启发式 TTL ≈ 2757s ≈ **46 分钟**(窗口内重启命中陈旧副本)。 +- `curl -H "Cache-Control: no-cache"` 仍 `200` 正常 → 客户端带 no-cache 即不再复用陈旧副本,证明 **C1 修复方向正确**。 +- 带 `?_t=Date.now()` 的 URL 经 `new URL()` 解析合法(pathname 正确、search 为 `_t=...`)→ **C2 修复方向正确**。 + +--- + +## 3. 设计方案(最终版 · 已整合 7 维审计) + +> 设计原则:**精准根治 + 配套必补项齐全 + 不过度设计**。 +> 改动分两类——**核心(绕过缓存层,根治)** 与 **辅助(主动刷新体验)**;其中辅助项含 5 个**必补配套**,否则功能/可靠性打折(详见第 4 节审计结论)。 + +### 3.1 核心(根治"重启/删除才有效")—— 绕过缓存层 +> 目标:列表/分类请求**永远拿最新**,不依赖删除缓存目录。 + +- **C1 请求头禁用缓存** + - `fetchWithRetry` 对数据 JSON 请求加 `headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }`。 + - 主进程 `electron/main/index.ts:653` 的 `onBeforeSendHeaders` 中,**仅对 `applist.json` / `categories.json` / `priority-config.json` 路径**注入 `Cache-Control: no-cache`(scope 严格过滤,绝不影响 `cdn.d.store...` 图标/截图加速)。双保险覆盖所有请求路径。 + +- **C2 URL 版本 bust(穿透 CDN 边缘缓存最稳手段)** + - 对上述数据请求 URL 追加 `?_t=${Date.now()}`,使每次 URL 不同,CDN 边缘与 Chromium 均无缓存可命中。 + - 代价:每次穿透 CDN,但列表文件小、频率低(启动/聚焦/手动),可接受。 + - **兼容性确认项**:上线前需在测试环境确认 `erotica.spark-app.store` 的 CDN 对含 query 的静态路径行为正常(极少数 CDN 有特殊规则);若有异常,回退为仅依赖 `no-cache` 或 path 版本戳。 + +### 3.2 辅助(主动刷新体验)—— 让用户无需重启即拿最新 +> 前提:3.1 已绕过缓存层。否则刷新也拿不到新数据。 + +- **A1 `loadApps` 幂等重建(必做,刷新安全前提)** + - 将 `App.vue:3216` 的 `apps.value.push` 改为:每个分类结果收集进 `Map`(复用现有 hybrid 合并逻辑),**该分类加载完即 `apps.value = [...map.values()]`**——保留增量提交(首个分类成功即关遮罩,不回退首屏体验),同时对所有模式(含 spark-only/apm-only)去重,反而比现状更稳。 + +- **A2 `refreshAppData()` 编排** + - 提取 `await loadCategories()`(本身重建安全,`App.vue:2946`)+ 重跑改造后的 `loadApps()`。覆盖主因与次要因素(新分类一并刷新)。 + +- **A3 两个刷新入口** + - **窗口聚焦(经主进程,非渲染 window focus)**:主进程 `win.on('focus')` → `webContents.send('window-focus')` → 渲染监听调用 `refreshAppData()`(Electron 无边框窗口的渲染 `window focus` 事件不一定可靠,必须走 IPC 才稳)。 + - **手动刷新按钮**:工具栏/侧边栏加刷新图标,调用 `refreshAppData()`。 + +### 3.3 必补配套(5 项,缺一不可) +| 编号 | 维度 | 配套项 | 解决风险 | +|---|---|---|---| +| **P1** | 功能正确 | A1 改造时**保留每分类增量提交** + 非 hybrid 也按 pkgname 去重 | 防首屏回退、防重复应用 | +| **P2** | 可靠性 | `refreshAppData`/`loadApps` 加**串行化守卫**(in-flight 合并或 `isRefreshing` 标志) | 防焦点/手动/启动并发竞态覆盖(`loadApps` 当前无锁,已确认) | +| **P3** | 可靠性 | 用**主进程 `win.on('focus')`→IPC** 而非渲染 `window focus` | 防无边框窗口 focus 事件不可靠导致刷新静默失效 | +| **P4** | 性能 | 聚焦刷新加**防抖(~3s)+ 节流** | 防切窗口抖动堆叠请求、穿透 CDN 放大流量 | +| **P5** | 可观测 | `refreshAppData` 入口打印 `[AppData] 触发刷新 (reason=focus\|manual)`;`applist` 加载后记录条目数变化(`1200 → 1201`) | 便于线上诊断"刷新是否真生效 / 新应用是否进内存" | + +### 3.4 可选 +- **O1 `loadTabApps` TTL 失效**:`App.vue:3098` 强缓存改"超过 TTL(如 10min)才重拉"。因 all 搜索主路径已由 3.1+3.2 覆盖,可后置。 + +--- + +## 4. 7 维专业审计结论(改动影响预评估) + +| 维度 | 影响评估 | 关键结论 | +|---|---|---| +| **1. 功能正确性** | 改善明确;A1/C2 改造需连带处理增量提交与去重(P1) | 根治"陈旧列表",但 C 改动需保增量体验 | +| **2. 性能** | C2 每次穿透 CDN,开销小;**唯一主要负向是聚焦频繁触发** | 需 P4 防抖;否则成主要性能负向 | +| **3. 安全性** | `no-cache`/时间戳 bust 中性;scope 必须限数据路径 | 不全局注入,不影响图标加速 | +| **4. 可靠性** | 两处真实风险:`loadApps` 无并发锁(竞态)、渲染 focus 不可靠 | 需 P2 串行化 + P3 IPC focus | +| **5. 兼容性** | `no-cache`/query 对所有 CDN/HTTP 通用;仅需一次 CDN 行为确认(C2) | 兼容性良好 | +| **6. 可维护性** | `refreshAppData` 抽出后逻辑集中、易测;增量代码小 | 维护性改善 | +| **7. 可观测/可测** | 当前刷新路径无专属日志;应补 P5 | 便于上线诊断 | + +**总体**:方案根治有效,性能/安全负向可控;**3 个必补配套(P1/P2/P3)+ 2 个强建议(P4/P5)** 须一并实现,否则功能正确性/可靠性打折。 + +--- + +## 5. 方案落地可行性验证(代码核对 · 测试,不改功能代码) + +> 本节为在动手前对方案各项做的**代码级可行性核对**(未修改任何源码),确认可实现性与需细化点。 + +### 5.1 逐项核对结论 + +| 项 | 代码事实(已核对) | 可行性 | 需细化点 | +|---|---|---|---| +| **C1 主进程 no-cache 注入** | `onBeforeSendHeaders` 在 `electron/main/index.ts:653` 注册,可拿 `details.url`(完整 URL,含 `.../applist.json` 等)。用 `url.includes('/applist.json')\|\|includes('/categories.json')\|\|includes('/priority-config.json')` 过滤注入即可 | ✅ 可行 | scope 必须限三路径,不影响 `cdn.d.store...` 图标 | +| **C2 URL bust** | 数据请求三入口(`loadApps` `App.vue:3203`、`loadTabApps` `3133/3151`、`loadCategories` `2925`)**均走 `fetchWithRetry`**;但 `loadPriorityConfig`(`storeConfig.ts:84`)走**独立 `priorityConfigAxios`,不经过 `fetchWithRetry`** | ✅ 可行 | **bust 须覆盖两个 axios 实例**;否则 `priority-config.json` 仍可能陈旧(影响 auto 策略,非搜索主路径但同源) | +| **A1 loadApps Map 幂等重建** | 当前 `App.vue:3216` `apps.value.push`,在 `Promise.all`(`3190`) 的 `category→origins` 异步循环内。改:函数内声明跨分类 `Map`,每分类 `origins` 完成后 `apps.value = [...map.values()]` | ✅ 可行 | 增量提交须保留在每个**分类**处理末尾(非等全部完成),否则首屏回退;Map 按 pkgname 作 key 天然覆盖 spark-only/apm-only 去重 | +| **A2 refreshAppData 编排** | 新函数 = `await loadCategories()` + 重跑改造后 `loadApps()`;与 `loadApps` 同作用域即可 | ✅ 可行 | 建议 `onMounted` 也改调 `refreshAppData` 而非裸 `loadApps`,统一入口 | +| **A3 + P3 主进程 focus→IPC** | 主进程 `win` 为模块级 `let win`(`electron/main/index.ts:122`),`createWindow` 内 `mainWindow`(`418`)。`mainWindow.on('focus', () => mainWindow.webContents.send('appdata-window-focus'))` 可靠(主进程事件,不受无边框影响) | ✅ 可行 | 渲染侧 `window.ipcRenderer.on('appdata-window-focus', ...)` 调用 `refreshAppData`;**preload 无需改动**(通用 `on` 通道已暴露) | +| **P2 串行化守卫** | `loadApps` 当前**无并发锁**(`loading.value` 仅遮罩),已确认 | ✅ 可行 | `refreshAppData` 内加 `refreshInFlight` Promise 合并:`if (inFlight) return inFlight; inFlight=(async()=>{...})().finally(()=>inFlight=null)` | +| **P4 防抖** | focus 抖动会快速多次触发 | ✅ 可行 | P2 的 in-flight 合并已天然防抖首跑;再叠加 time-based 节流(如 10s 内仅一次)更稳 | +| **P5 刷新日志** | 既有 Pino `logger`(如 `App.vue:3205` `logger.info('加载分类...')`) | ✅ 可行 | `refreshAppData` 入口 `[AppData] 触发刷新 reason=...`;`loadApps` 完成 `[AppData] 应用数 X → Y`(Y=map.size) | + +### 5.2 关键发现(影响方案完整性) + +1. **C1 主进程注入是 C2 的兜底**:主进程 `onBeforeSendHeaders` 注入 `no-cache` 对**所有**经 Chromium 的请求(含 `priorityConfigAxios` 的 XHR)生效;即便 C2 漏给 `priorityConfigAxios` 加 bust,C1 也能保证其不读缓存。**建议 C1 为主、C2 为辅**,两者互补而非二选一。 +2. **C2 必须显式覆盖 `priorityConfigAxios`**:因其独立于 `fetchWithRetry`,要在 `storeConfig.ts:84` 的 `priorityConfigAxios.get(configPath)` 处单独加 bust(或在 `priorityConfigAxios` 实例层加请求拦截器统一加 `?_t`)。否则 auto 策略配置可能陈旧。 +3. **loadApps 异步嵌套**:当前 `categoriesList.map(async category => origins.map(async mode => ...))` 嵌套异步;A1 改造时,需把内层改为 `await Promise.all(origins.map(...))` 后在分类回调末尾提交 `apps.value`,确保"每分类增量提交"。 +4. **下游无回归**:`apps.value` 整体替换会触发 Vue 响应式,`baseApps`/`filteredApps`/排行均依赖它,赋值即刷新,无兼容问题。 +5. **preload 零改动**:新 IPC 通道 `appdata-window-focus` 直接 `send`/`on`,复用已暴露的通用 `ipcRenderer`,不需改 `contextBridge`。 + +### 5.3 测试结论 + +- **全部 8 个方案项(C1/C2/A1/A2/A3 + P1~P5)均可实现,无不可落地项。** +- 落地前须落实 5 个细化点(5.1 表"需细化点" + 5.2 关键发现),其中 **C2 双 axios 覆盖** 与 **C1 兜底关系** 是最易被遗漏、却影响完整性的两点。 +- 风险等级:均为低~中,无高危阻塞;P2(竞态)与 A3 的 IPC 可靠性属"不做则功能打折"的必补项,已在方案中标明。 + +### 5.4 实际效果验证(2026-08-11,本机可执行部分已实测) + +> 用户要求验证"实际效果"。区分两类验证: +> - **(a) 机理实证(本机已完成)**:用 curl/Node 验证根因与修复方向正确性,无需 GUI/服务端改动。 +> - **(b) 端到端实证(需真机+服务端上架新应用,本机无法完成)**:见第 7 节,须由用户在真实环境执行。 + +**(a) 已完成的本机实证:** +1. `curl -sI` 远程 `applist.json`:**无 `cache-control` 头、`server: nginx`** → 证实 Chromium 启发式缓存为真因(非 CDN 主动缓存)。 +2. Node 计算启发式 TTL ≈ 46 分钟 → 量化"重启仍陈旧"的窗口。 +3. `?_t=Date.now()` URL 经 `new URL()` 解析合法 → C2 方向正确。 +4. `curl -H "Cache-Control: no-cache"` 仍 200 → 客户端带 no-cache 即绕过陈旧副本,C1 方向正确。 +5. 串行化守卫 / Map 幂等去重 / focus→IPC 逻辑已在 5.1 核对,均为纯逻辑可单测(实现后补单测即闭环)。 + +**(b) 尚待真机验证(非本机能力范围):** +- 需在**服务端上架一个真实新应用**(最好落入已存在分类,隔离"新分类"变量)。 +- 安装修复包后,**不删缓存、不重启** → 聚焦/刷新 → 搜到新应用(验证 C1/C2 绕过启发式缓存)。 +- 重启不清缓存 → 首屏即显示(验证重启不再拿旧数据)。 +- 对照旧包同样操作需删 `Cache` 才有效 → 根因闭合。 +- 说明:本机为无显示环境(headless Linux),且无法操作远端商店后台上架,故 (b) 必须用户在真实环境执行;**但根因机理已由 (a) 实证闭合,修复方向确凿**。 + +--- + +## 6. 明确不做(避免过度设计) + +- ❌ **定时轮询**(后台常驻网络/CPU 开销,用户未要求实时)。 +- ❌ **本地磁盘离线缓存**(问题不在离线,反而引入陈旧风险)。 +- ❌ **ETag / 304 协商缓存**(axios 不过系统缓存层,且已有 no-cache + bust 更稳;除非确认代理忽略请求头才需要)。 +- ❌ **全局 `Cache-Control` 注入到所有请求**(仅对数据 JSON 路径注入,避免影响图标/截图等静态资源加速)。 + +--- + +## 6. 验证方法 + +> **验证分工**:步骤 1(单测)随实现完成;步骤 2–5(端到端)需服务端上架新应用 + 真实 GUI 环境,**由用户在真机执行**(本机 headless 无法完成)。 + +1. **单元/逻辑**:`loadApps` 幂等化后补测——重跑两次 `apps.value` 长度不变、无重复 pkgname;`refreshAppData` 串行化 + 防抖单测。 +2. **集成(根因闭合)**:服务端上架新应用(落入已存在分类,隔离"新分类"变量)。 +3. 安装修复包,**不删缓存、不重启** → 聚焦窗口 / 点刷新 → 应搜到新应用(证明缓存已绕过,P5 日志应显示条目数 +1)。 +4. 重启应用(不清缓存)→ 首屏即显示新应用(证明 no-cache + bust 生效,重启不再拿旧数据)。 +5. **对照旧包**:同样操作需"删除 `~/.config/spark-store/Cache` 才有效"——确认根因闭合、修复生效。 + +> 注:机理层面已由第 5.4 节本机 curl/Node 实证闭合(nginx 无 CC 头 → Chromium 启发式缓存 ≈46min;C1/C2 方向正确),故修复方向确凿,端到端仅作最终确认。 + +--- + +## 附:历次分析演进 + +- **第一轮**:怀疑 `~/.cache` 本地应用列表文件缓存 → 核查排除(无磁盘列表缓存,搜索基于内存)。 +- **第二轮**:深入确认"运行期不刷新"为主因(`loadApps` 仅启动一次、append 无去重、`loadTabApps` 强缓存、categories 启动一次)。 +- **第三轮**:结合"重启无效、删除才行"反馈,定位为 **Chromium 磁盘缓存 + CDN 边缘缓存**双重陈旧,且代码零缓存失效机制;修正上轮结论——重启无效的根因是缓存层喂旧数据,而非单纯内存不刷新。 +- **第四轮(方案验证)**:按用户要求"先测试不改代码",对最终方案的 8 项(C1/C2/A1/A2/A3 + P1~P5)做代码级可行性核对,确认全部可落地;补充 5 个细化点(C2 须覆盖 `priorityConfigAxios` 独立实例、C1 主进程注入为兜底、loadApps 异步嵌套改造、下游无回归、preload 零改动),写入第 5 节。方案待用户审核后决定是否实现。 + +--- + +## 8. 附加修复:下载日志断层(下载卡在"正在获取 Metalink"突兀退出) + +### 8.1 现象(用户反馈) +下载应用时日志停在: +``` +[13:57:09] 开始下载... +[13:57:09] 正在获取 Metalink 文件: amd64-store/office/com.qianwen.otohime/com.qianwen.otohime_3.7.5.145_amd64.deb.metalink +``` +随后界面无过渡直接结束(状态变 failed / 任务消失),中间无任何失败原因,体验突兀。 + +### 8.2 根因 +下载流程在 `electron/main/backend/install-manager.ts` 的 `runDownloadPhase`: +1. **Metalink 下载请求失败无前端日志**:`await axios.get(...)` 抛错时,原代码未 `sendLog`,错误只在主进程 `logger.error` 静默记录;错误向上抛到外层 catch → 直接发 `install-complete {success:false}`,渲染端日志面板永远停在"正在获取 Metalink"。 +2. **aria2c 拉起失败也无日志**:`child.on("error")` 仅 `reject(err)`,渲染端同样看不到原因。 +3. **渲染端 `install-complete` 失败分支不写 logs**:`src/modules/processInstall.ts` 收到失败时只改 `status="failed"`,未把 `log.message` 的失败原因写入 `downloadObj.logs`,导致日志卡在最后一条中间状态。 + +三者叠加:任一环节失败,UI 都在"正在获取 Metalink"后突兀结束且无原因。 + +### 8.3 修复(已提交) +- **F1(主进程 Metalink 失败日志)**:`axios.get` 用 try/catch 包裹,失败时 `sendLog("获取 Metalink 失败: ")` 并 `throw`,保证渲染端能显示原因。Metalink 写入 `finish` 回调补 `sendLog("Metalink 文件下载完成")`。 +- **F2(aria2c 拉起失败日志)**:`child.on("error")` 补 `sendLog("aria2c 启动失败: ")`。 +- **F3(渲染端失败原因入日志)**:`install-complete` 的 `else` 分支解析 `log.message` 中的 `message` 字段,push 一条 `下载失败: ` 到 `downloadObj.logs`;成功分支补 `下载完成`。 + +改动文件: +- `electron/main/backend/install-manager.ts`(F1、F2) +- `src/modules/processInstall.ts`(F3) + +> 注:本修复仅改善"失败可见性",不改变下载/重试/安装逻辑本身;UI 不再突兀卡在中间日志。 + +### 8.4 验证边界 +本机 headless 无法真实触发 aria2c 下载与 GUI 日志面板;已通过 `vue-tsc` 类型检查与 `read_lints` 0 错误。真实失败场景(如 Metalink 404、网络中断)下的日志连贯性需用户在真机验证。 + +--- + +## 9. 附加修复:更新中心点击"更新"后卡"开始更新..." + +### 9.1 现象(用户反馈) +在软件更新中心选择 Visual Studio Code: 点击更新后,下载详情弹窗日志停在: +``` +[09:23:41] 开始更新... +``` +状态为 `queued`,后续无进展、无错误,界面卡死。 + +### 9.2 根因 +更新中心 `electron/main/backend/update-center/service.ts` 的 `start()` 方法,把任务通过 `webContents.send("queue-install", JSON.stringify(installTaskData))` 发送给主下载队列。 + +**但 `webContents.send` 是从主进程向渲染进程发消息,渲染进程的 `ipcRenderer.on("queue-install")` 能收到,而主进程的 `ipcMain.on("queue-install") 监听的是渲染进程 `ipcRenderer.send` 的消息,监听不到自己 `webContents.send` 的消息。** + +结果:任务根本没有进入 `install-manager.ts` 的下载队列,`processNextDownload()` 永远不会执行,UI 自然卡在"开始更新...」。 + +### 9.3 修复(已提交) +- **I1(抽离可复用的入队函数)**:在 `electron/main/backend/install-manager.ts` 新增导出 `addInstallTask(payload, sender)`,把原来 `ipcMain.on("queue-install")` 里的解析、校验、去重、APM 检查、命令构建、入队逻辑全部抽到该函数。`ipcMain.on` 本身只做 JSON 解析并调用 `addInstallTask`。 +- **I2(更新中心直接调用入队)**:`electron/main/backend/update-center/service.ts` 的 `start()` 不再 `webContents.send("queue-install")`,而是直接 `await addInstallTask(installTaskData, webContents)`,使任务真正进入主下载队列。 +- **I3(类型安全)**:新增 `QueueInstallPayload` 接口,避免 `any`。 + +改动文件: +- `electron/main/backend/install-manager.ts`(I1、I3) +- `electron/main/backend/update-center/service.ts`(I2) + +### 9.4 验证边界 +本机 headless 无法启动 GUI 触发真实更新下载;已通过 `vue-tsc` 与 `read_lints` 0 错误。真机需在软件更新中心勾选一项更新并点击"更新",确认日志从"开始更新..."推进到"正在获取 Metalink 文件"、下载进度增加,最终进入安装或明确失败。 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/docs/testing-records.md b/docs/testing-records.md new file mode 100644 index 00000000..4fc13aa3 --- /dev/null +++ b/docs/testing-records.md @@ -0,0 +1,178 @@ +# 测试记录文档(Testing Records) + +> 用途:每次**修改代码后打包测试**都要在此追加一条记录,避免再次出现「改了 URL/逻辑后更新功能异常」这类回归问题。 +> 维护约定(用户 2026-08-13 明确要求): +> 1. **每次修改代码都必须打包测试**(开发/自测统一用 `./scripts/test-build.sh`,产物带 `-test` 标签)。 +> 2. 打包前先在此文档新增一条记录:填写修改功能、建议测试方式、预期结果。 +> 3. 打包完成(真机或构建通过后)**回填测试结果**(通过 / 失败 + 现象 + 修复动作)。 +> 4. 涉及更新中心 / 下载 / 安装 / metalink / 网络 URL 等改动时,必须包含「更新中心实际勾选升级一个应用,观察日志是否推进到下载与安装」这一回归项。 + +--- + +## 记录格式(复制此模板填写) + +``` +### [版本号] 日期 — 一句话主题 +- 修改功能: +- 涉及文件: +- 建议测试方式: +- 预期结果: +- 构建验证:vue-tsc / 打包(PASS / FAIL) +- 测试结果:(待回填) + - 真机现象: + - 结论:通过 / 失败 + - 失败修复: +``` + +--- + +## 历史记录 + +### [5.2.1.31-test] 2026-08-13 — 更新中心 hold 锁定标签 + 强制安装 + 修复安装失败误报"下载完成" +- 修改功能: + 1. 更新中心识别 `apt-mark hold` 锁定的包:默认不可选中,显示「已锁定」标签;提供「强制安装」开关,开启后 spark 源 ssinstall 追加 `--allow-change-held-packages`。 + 2. 修复安装失败误报:ssinstall 放弃安装时仍以退出码 0 结束,原逻辑误判成功导致 UI 显示「下载完成」;现检测 `放弃安装`/`dry-run测试仍然失败` 等标志判失败,并按任务来源显示「更新完成/更新失败」或「安装完成/安装失败」。 +- 涉及文件: + - 后端:`update-center/types.ts`、`update-center/index.ts`、`update-center/service.ts`、`install-manager.ts` + - 前端:`typedefinition.ts`、`modules/updateCenter.ts`、`modules/processInstall.ts`、`components/update-center/UpdateCenterItem.vue`、`components/update-center/UpdateCenterList.vue`、`components/UpdateCenterModal.vue` +- 建议测试方式: + 1. 更新中心打开,确认被 hold 的包(如 `code`)显示「已锁定」、复选框禁用、顶部提示条出现。 + 2. 勾选其它普通更新项 → 开始更新 → 观察日志推进到「正在获取 Metalink」→ 下载进度 → 安装完成(不是「下载完成」)。 + 3. 打开 `code` 的「强制安装」开关 → 复选框可用、标签变「将强制」→ 勾选并升级 → 观察是否正常升级(需本机 `code` 被 hold 才能验证)。 + 4. 回归:用「更新中心实际升级一个普通应用」验证 metalink/下载/安装全链路未被本次改动破坏(此前 28-test 因 metalinkUrl 域名误杀出过同类问题)。 +- 预期结果:hold 项默认不可选;强制开关可单独升级 hold 包;安装失败时明确显示「安装失败」而非「下载完成」;普通更新链路正常。 +- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.31-test_amd64.deb`。 +- 测试结果:真机验证发现"失效"现象(见下方 32-test 修复说明)。 + - 真机现象:用户装 31-test 后从更新中心点更新,日志仅 `[14:28:23] 开始更新...` 一行后无进展。 + - 结论:非代码 bug,是 31 新增的 hold 拦截**正确行为**——用户测试的包(code)被 `apt-mark hold` 锁定,31 默认拦截它;但用户未开启该行的「强制安装」开关就点开始更新,后端 `start()` 把它过滤掉后**静默 return**,前端无任何提示,造成"只有一行日志"的观感。 + - 失败修复:32-test 修复后端 `start()` 静默拦截问题——对被 hold 且未强制的选中项,明确向前端发送 `install-complete` 失败通知(提示"已在更新中心默认跳过,请开启强制安装后重试"),不再静默卡住。 + +### [5.2.1.32-test] 2026-08-13 — 修复被 hold 包未强制时更新中心"静默卡开始更新" +- 修改功能:后端 `update-center/service.ts` 的 `start()` 不再对"被锁定(hold)且未开启强制安装"的选中项静默跳过。改为:先分离可启动项与被锁拦截项;对被锁拦截项向前端发送明确的 `install-complete` 失败通知(携带提示文案);仅对真正可启动的项调用 `addInstallTask`。前端 `processInstall.ts` 的 install-complete handler 会将失败解析为「更新失败:...」,用户能看到原因而非停在「开始更新...」。 +- 涉及文件:`update-center/service.ts`(仅后端;前端复用已有的 install-complete 失败显示逻辑,无需改)。 +- 建议测试方式: + 1. 更新中心勾选一个**普通(未 hold)**的更新项 → 开始更新 → 日志应推进到「正在获取 Metalink」→ 下载进度 → 更新完成(确认 31 的 hold 改动未破坏普通更新链路)。 + 2. 勾选一个**被 hold** 的包(如 `code`)且**不开**其「强制安装」开关 → 开始更新 → 日志应显示「更新失败:...被系统锁定(hold)...请开启强制安装后重试」,而非只有「开始更新...」。 + 3. 开启该 hold 包的「强制安装」开关 → 勾选并升级 → 应正常进入下载/安装(验证强制路径)。 +- 预期结果:普通更新正常;hold 未强制时给出明确失败提示而非静默卡住;hold 强制后可正常升级。 +- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.32-test_amd64.deb`。 +- 测试结果:失败。 + - 真机现象:用户装 32-test 后,对 `code` 开启「强制安装」开关再点「更新选中」,日志仍只有 `[14:59:28] 开始更新...` 一行后无进展;同时「更新选中」后该包从更新中心列表消失,未保留。 + - 失败原因: + 1. `service.ts` 的 `start()` 中 `taskIdByKey` 只存储了 `task.id`(数字),后续读取 `taskIdByKey.get(taskKey)?.forceHeld` 永远为 `undefined`,导致「强制安装」开关状态丢失;被 hold 包即使开启强制仍被当作未强制拦截。 + 2. held 阻断通知代码里 `webContents` 在通知循环之后才获取,导致通知实际未发出(但 32 中因原因 1 已把强制项也拦截,所以主要表现为卡住)。 + 3. `start()` 启动任务后主动从 `currentItems` 过滤掉已启动项,导致用户点击「更新选中」后包立刻从更新中心消失。 + - 失败修复:见 33-test 记录。 + +### [5.2.1.33-test] 2026-08-13 — 修复强制安装开关失效 + 更新选中后列表消失 + 工具栏布局优化 +- 修改功能: + 1. 修复 `update-center/service.ts` 中 `taskIdByKey` 仅保存 `id` 导致 `forceHeld` 丢失的 bug:改为保存完整 `UpdateCenterStartTask` 对象,确保「强制安装」开关状态能正确传递到 `addInstallTask`。 + 2. 提前获取 `webContents` 到 held 阻断通知之前,确保被 hold 且未强制的项能向前端发送明确的 `install-complete` 失败通知。 + 3. 移除 `start()` 中启动后从更新中心 `items` 删除已选包的逻辑,保持列表不变,等待用户刷新或任务完成后再消失。 + 4. 优化 `UpdateCenterToolbar.vue`:将「全选」复选框和已选计数移到「更新选中」按钮同一行,缩短选择-执行操作路径。 +- 涉及文件: + - 后端:`electron/main/backend/update-center/service.ts` + - 前端:`src/components/update-center/UpdateCenterToolbar.vue` +- 建议测试方式: + 1. 更新中心勾选普通未 hold 包 → 开始更新 → 日志推进到「正在获取 Metalink」→ 下载/安装完成(非「下载完成」、非卡死)。 + 2. 被 hold 包(如 `code`)不开「强制安装」→ 开始更新 → 明确提示「更新失败:...被系统锁定(hold)...请开启强制安装后重试」,不只有「开始更新...」。 + 3. 被 hold 包开启「强制安装」→ 勾选并升级 → 应正常进入下载/安装流程(ssinstall 命令带 `--allow-change-held-packages`)。 + 4. 点击「更新选中」后,已选包仍保留在更新中心列表中,不会立即消失。 + 5. 观察更新中心工具栏:「全选」复选框、已选计数、「更新选中」按钮在同一行右侧,操作便捷。 +- 预期结果:强制安装真正生效;hold 未强制有明确失败提示;普通更新链路正常;更新选中后列表保留;工具栏操作更紧凑。 +- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.33-test_amd64.deb`。 +- 测试结果:通过(用户确认优化与更新处理正确)。 + - 真机现象:用户装 33-test 后确认强制安装开关生效、hold 未强制有明确失败提示、普通更新链路正常。 + - 结论:通过 + - 遗留反馈:选中执行更新后,包进入下载列表却仍显示在更新中心(33-test 误删了"从列表移除已启动项"逻辑);且全选按钮在无可选项时仍可点、无提示。两处已在 34-test 修复。 + +### [5.2.1.34-test] 2026-08-13 — 恢复更新后从列表移除 + 无可选项时全选禁用提示 +- 修改功能: + 1. 恢复 33-test 误删的逻辑:更新中心「更新选中」启动任务后,已启动项从更新中心 `items` 移除(被 hold 未强制拦截的项不在此列,仍保留供用户开强制重试)。更新中心只展示待更新项,避免与下载队列重复显示。 + 2. `modules/updateCenter.ts` 新增 `selectableCount` computed(可选项数 = 未被忽略且非"held 未强制"的项数)。 + 3. `UpdateCenterToolbar.vue`:当 `selectableCount === 0`(全是 hold 未强制、全是忽略项、或完全没有可更新软件)时,全选复选框 `:disabled` 且整体置灰,右侧文案改为「无可更新项」并带提示 title。 +- 涉及文件: + - 后端:`electron/main/backend/update-center/service.ts` + - 前端:`src/modules/updateCenter.ts`、`src/components/UpdateCenterModal.vue`、`src/components/update-center/UpdateCenterToolbar.vue` +- 建议测试方式: + 1. 勾选若干普通更新项 → 更新选中 → 这些包应从更新中心列表消失,仅出现在下载队列(之前 33-test 会残留,现恢复)。 + 2. 当列表里所有项都是 hold 未强制 / 已忽略,或列表为空时:全选复选框应禁用置灰、文案显示「无可更新项」,点击无反应;「更新选中」按钮也因无选中而禁用。 +- 预期结果:更新选中后不在更新中心残留;无可选项时全选禁用并提示。 +- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.34-test_amd64.deb`。 +- 测试结果:待真机回填(本机无 GUI)。34-test 用户已确认更新后从列表移除、全选禁用提示均生效;但反馈开启强制后 code 仍更新失败(见 35-test 根因)。 + - 真机现象: + - 结论:通过 / 失败 + - 失败修复: + +### [5.2.1.35-test] 2026-08-13 — 修复强制安装在 ssinstall 本地文件模式误加 apt 标志 +- 修改功能:`install-manager.ts` 中 spark 源的 ssinstall 命令构建逻辑。此前对**本地 .deb 文件模式**(`metalinkUrl && filename`)也追加了 `--allow-change-held-packages`,但该选项不是 ssinstall 支持的参数(ssinstall 用法 `ssinstall [选项] `,选项仅自身专用),会被其透传给内部 `dirname`/`basename` 导致参数解析失败、包名丢失,进而 `E: 无法定位软件包` / `Package manager quit with exit code` 失败。 + - 本地 .deb 文件模式:改走 `dpkg` 直接安装,dpkg 安装本地文件**不受 apt `hold` 限制**(hold 仅拦截 `apt install <包名>` 从仓库升级),故**不再追加**该标志。 + - 仓库模式(`ssinstall pkgname`):从 apt 仓库拉取,hold 会拦截,保留 `forceHeld` 时追加 `--allow-change-held-packages`。 +- 涉及文件:`electron/main/backend/install-manager.ts`(仅后端)。 +- 建议测试方式:开启 `code`(被 hold)的「强制安装」开关 → 勾选升级 → 日志应正常进入 ssinstall 本地 .deb 安装,不再出现 `dirname/basename 未识别的选项` 与 `无法定位软件包`,最终「更新完成」。 +- 预期结果:强制安装本地 .deb 不再因误加 apt 标志而失败;普通(未 hold)与仓库模式行为不变。 +- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.35-test_amd64.deb`。 +- 测试结果:失败(用户 15:55 日志)。 + - 真机现象:开 code 强制 → 更新 → `E: 在更改保留软件包的同时使用了 -y 选项,但没有搭配 --allow-change-held-packages.` → `dry-run测试仍然失败,放弃安装` → 更新失败。 + - 根因:实测证实 ssinstall 本地 .deb 模式在 `dpkg -i` 失败后兜底 `aptss install -yfq`,aptss/apt 对 held 包用 `-y` 强制要求 `--allow-change-held-packages`;而 ssinstall 不识别该参数(透传给 dirname/basename 崩溃),故 35-test 去掉标志后仍被 apt 拒绝。35-test 方案(依赖 dpkg 不拦 hold)不成立。 + - 失败修复:见 36-test(改用 unhold/hold 包裹策略)。 + +### [5.2.1.36-test] 2026-08-13 — 强制安装改用 apt-mark unhold/hold 包裹(绕开 ssinstall 不支持该标志) +- 修改功能:`install-manager.ts` 的强制安装(forceHeld)策略从"传 `--allow-change-held-packages` 给 ssinstall"改为"安装前 `pkexec apt-mark unhold`、安装后(finally)`pkexec apt-mark hold` 恢复锁定"。 + - 新增模块函数 `runAptMark(action, pkgname)`:`checkSuperUserCommand()` 取 pkexec,直接用 `pkexec apt-mark pkgname`(**不经过 shell-caller.sh**,因其白名单仅放行 apm/aptss/ssinstall,否则报"拒绝执行")。 + - `runInstallPhase`:`try` 开头若 `task.forceHeld && task.origin==='spark'` 先 `runAptMark("unhold")`,`finally` 中无论成败都 `runAptMark("hold")` 恢复原锁定状态。 + - 移除 spark 源 ssinstall 命令中任何 `--allow-change-held-packages`(ssinstall 不识别)。 +- 涉及文件:`electron/main/backend/install-manager.ts`(仅后端;forceHeld 字段链路 service.ts→QueueInstallPayload→task 已就绪)。 +- 建议测试方式:开启 `code`(被 hold)的「强制安装」开关 → 勾选升级 → 日志应出现「正在解除系统锁定(hold)...」→ ssinstall 本地安装成功 → 最终「更新完成」;另查 `apt-mark showhold` 确认安装后 code 仍被锁定(状态恢复)。 +- 预期结果:强制安装 held 包成功;安装后 hold 状态自动恢复,不破坏用户原本的锁定。 +- 构建验证:vue-tsc 通过(exit 0);`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.36-test_amd64.deb`。 +- 测试结果:未真机验证即发现更优方案(见 37-test)。36-test 的 `runAptMark` 用 `pkexec apt-mark` 会弹额外权限框,且 apt-mark 不在 policykit 免密 exec.path 内可能直接失败。 + - 失败修复:见 37-test(合并进商店已有免密 pkexec)。 + +### [5.2.1.37-test] 2026-08-13 — 强制安装合并进免密 pkexec(不重复请求权限) +- 修改功能:用户指出"商店已有提权配置,unhold/hold 不必再请求权限"。调查确认 policykit 规则(`extras/store.spark-app.spark-store.policy`、`pkg/.../ssinstall.policy`)仅对 `exec.path=/opt/spark-store/extras/shell-caller.sh` 与 `/usr/local/bin/ssinstall` 设 `allow_any=yes`(免密)。`pkexec apt-mark` 不在任何 policy 的 exec.path → 弹额外密码框且可能失败。 + - `extras/shell-caller.sh` 新增 `force-ssinstall` 分支:在一次已提权的 pkexec 会话内执行 `apt-mark unhold ` → `ssinstall [额外参数] --native` → `apt-mark hold `;unhold 失败仅警告不阻断,hold 无论成败都恢复;参数用双引号防注入、空参校验。 + - `install-manager.ts`:`forceHeld && origin==='spark' && 本地 .deb` 时,构造命令走 `shell-caller force-ssinstall --delete-after-install --no-create-desktop-entry`(即复用 ssinstall 那条免密 pkexec,仅一次权限请求);移除 36-test 的 `runAptMark` helper 及 runInstallPhase 的 try/finally 单独 pkexec apt-mark 调用。 +- 涉及文件:`extras/shell-caller.sh`、`electron/main/backend/install-manager.ts`。 +- 建议测试方式:开启 `code`(被 hold)「强制安装」→ 勾选升级 → 应**仅弹一次**权限框(与平时 ssinstall 一致)→ 日志 ssinstall 本地安装成功 → 更新完成;装后 `apt-mark showhold` 确认 code 仍锁定(已自动恢复)。 +- 预期结果:强制安装成功且只请求一次权限;hold 状态自动恢复。 +- 构建验证:vue-tsc 通过(exit 0);`bash -n shell-caller.sh` 语法 OK;`scripts/test-build.sh` 打包成功,产物 `spark-store_5.2.1.37-test_amd64.deb`。 +- 测试结果:待真机回填(本机无 GUI)。 + - 真机现象: + - 结论:通过 / 失败 + - 失败修复: + +### [5.2.1.29-test / 5.2.1.30-test] 2026-08-13 — 修复更新中心卡"开始更新..." + metalinkUrl 域名误杀清理 +- 修改功能: + 1. 29-test 修复 metalinkUrl 校验写死 `erotica.spark-app.store` 误杀真实 CDN 域 `d.spark-app.store`:放宽为 `*.spark-app.store` 全子域 + https + path.posix.normalize 折叠 `./` + 拒绝 `..` 越界。 + 2. 30-test 清理调试期噪声([debug] 日志、FILENAME_PATTERN 拆分、stream error 监听、processInstall 自监听),保留真正修复。 +- 涉及文件:`install-manager.ts`、`processInstall.ts` +- 建议测试方式:更新中心勾选并升级 `trae-cn` 等普通应用,观察日志推进到「正在获取 Metalink 文件」→ 下载进度。 +- 预期结果:更新中心更新功能恢复正常,不再卡「开始更新...」或「下载失败: Metalink URL 不合法」。 +- 构建验证:vue-tsc 通过;打包成功(29/30-test)。 +- 测试结果:用户确认 29-test 修复有效。 + - 真机现象:29-test 更新中心升级 trae-cn 正常推进到下载阶段。 + - 结论:通过 + +### [5.2.1.26-test ~ 5.2.1.28-test] 2026-08-13 — 更新中心"更新"卡死的多次排查 +- 修改功能: + 1. 26-test:抽离 `addInstallTask(payload, sender)`,`service.ts` 的 `start()` 直接调用(修复 `webContents.send("queue-install")` 主进程自环 bug,任务从未入队);并加 FILENAME_PATTERN、各 return 分支补 install-complete 通知、metalink 流错误监听、[debug] 日志(部分属调试噪声,后续 30-test 回退)。 + 2. 28-test:暴露真正根因 metalinkUrl 域名校验误杀 `d.spark-app.store`(见 29-test 修复)。 +- 涉及文件:`install-manager.ts`、`update-center/service.ts` +- 建议测试方式:更新中心实际升级应用 + 普通安装对照。 +- 预期结果:更新中心任务能真正进入下载队列并推进。 +- 构建验证:vue-tsc 通过;打包成功。 +- 测试结果: + - 28-test 日志首次暴露 metalinkUrl 域名误杀(见上方 29-test 修复)。 + - 结论:根因定位完成,29-test 修复通过 + +--- + +## 回归测试 checklist(每次打包必做核心项) + +- [ ] `vue-tsc --noEmit` 通过,`scripts/test-build.sh` 打包成功 +- [ ] **更新中心**:勾选并升级一个普通应用,日志推进 开始更新 → 正在获取 Metalink → 下载进度 → 安装完成(确认不是「下载完成」且非「开始更新...」卡死) +- [ ] **普通安装**:应用详情页安装一个应用,确认链路正常 +- [ ] **忽略功能**:更新中心忽略/取消忽略一项,确认沉底与不可选 +- [ ] **hold/强制**(若改动更新中心):被 hold 包默认不可选、强制开关可单独升级 +- [ ] 安装失败时 UI 明确显示「安装失败」而非「下载完成」 diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index 5eee6ac7..fe656f6e 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"); @@ -30,6 +50,18 @@ const isOriginEnabled = ( return storeFilter === "both" || storeFilter === origin; }; +export interface QueueInstallPayload { + id: number; + pkgname: string; + metalinkUrl?: string; + filename?: string; + origin?: "spark" | "apm"; + upgradeOnly?: boolean; + retry?: boolean; + // 强制安装被系统锁定(apt-mark hold)的包:spark 源安装时追加 --allow-change-held-packages + forceHeld?: boolean; +} + type InstallTask = { id: number; pkgname: string; @@ -44,6 +76,7 @@ type InstallTask = { origin: "spark" | "apm"; upgradeOnly?: boolean; cancelled?: boolean; + forceHeld?: boolean; phase: "queued-download" | "downloading" | "queued-install" | "installing"; }; @@ -263,36 +296,166 @@ const parseUpgradableList = (output: string) => { return apps; }; -// Listen for download requests from renderer process -ipcMain.on("queue-install", async (event, download_json) => { - const download = - typeof download_json === "string" - ? JSON.parse(download_json) - : download_json; - const { id, pkgname, metalinkUrl, filename, origin, upgradeOnly } = - download || {}; +/** + * 将任务加入安装/下载队列。被 IPC 监听与更新中心等内部模块共享使用。 + * 入队前会执行包名、文件名、metalinkUrl 的校验,并检查重复任务。 + * @returns 是否成功入队 + */ +export const addInstallTask = async ( + download: QueueInstallPayload, + sender: WebContents | null, +): Promise => { + const webContents = sender; + const { id, pkgname, filename, origin, upgradeOnly, forceHeld } = download; + // metalinkUrl 可能在下方校验时被规范化(折叠 ./ 段),故用 let + let { metalinkUrl } = download; if (!id || !pkgname) { - logger.warn("passed arguments missing id or pkgname"); - return; + logger.warn("addInstallTask: passed arguments missing id or pkgname"); + webContents?.send("install-complete", { + id: id ?? 0, + success: false, + time: Date.now(), + exitCode: -1, + message: JSON.stringify({ + message: "任务参数缺失 id 或 pkgname", + stdout: "", + stderr: "", + }), + }); + return false; + } + + // 包名/文件名白名单校验:防止路径遍历(如 ../../)或非法字符进入下载目录与安装命令构建 + if (!PKGNAME_PATTERN.test(pkgname)) { + logger.warn(`addInstallTask invalid pkgname: ${pkgname}`); + webContents?.send("install-complete", { + id, + success: false, + time: Date.now(), + exitCode: -1, + message: JSON.stringify({ + message: `包名包含非法字符: ${pkgname}`, + stdout: "", + stderr: "", + }), + }); + return false; + } + if (filename && !PKGNAME_PATTERN.test(filename)) { + logger.warn(`addInstallTask invalid filename: ${filename}`); + webContents?.send("install-complete", { + id, + success: false, + time: Date.now(), + exitCode: -1, + message: JSON.stringify({ + message: `文件名包含非法字符: ${filename}`, + stdout: "", + stderr: "", + }), + }); + return false; + } + + // metalinkUrl 来自渲染端(由目录 filename / 主进程解析的 downloadUrl 拼成)。 + // 纵深防御:仅允许 https 协议、官方域名(含任意子域 *.spark-app.store)的绝对 URL, + // 或以 "/" 开头的相对路径(后续由 axios baseURL 拼接官方域名)。 + // 先解析并规范化 path 中的 "./" 段(浏览器/Node 实际请求时会自动折叠,此处显式处理, + // 避免把合法的 "./" 误判为非法),再拒绝 ".." 路径遍历越界与外部域名(防 SSRF)。 + if (metalinkUrl) { + let parsed: URL; + try { + // 相对路径以官方域为基进行解析 + parsed = new URL(metalinkUrl, "https://erotica.spark-app.store"); + } catch { + logger.warn(`addInstallTask invalid metalinkUrl (parse failed): ${metalinkUrl}`); + webContents?.send("install-complete", { + id, + success: false, + time: Date.now(), + exitCode: -1, + message: JSON.stringify({ + message: `Metalink URL 不合法: ${metalinkUrl}`, + stdout: "", + stderr: "", + }), + }); + return false; + } + + if (parsed.protocol !== "https:") { + logger.warn(`addInstallTask invalid metalinkUrl (not https): ${metalinkUrl}`); + webContents?.send("install-complete", { + id, + success: false, + time: Date.now(), + exitCode: -1, + message: JSON.stringify({ + message: `Metalink URL 协议不合法: ${metalinkUrl}`, + stdout: "", + stderr: "", + }), + }); + return false; + } + + // 仅允许官方域名(主域 spark-app.store 及其任意子域,如 erotica/d/store 等) + const host = parsed.hostname; + const isOfficial = + host === "spark-app.store" || host.endsWith(".spark-app.store"); + if (!isOfficial) { + logger.warn(`addInstallTask invalid metalinkUrl (host not official): ${host}`); + webContents?.send("install-complete", { + id, + success: false, + time: Date.now(), + exitCode: -1, + message: JSON.stringify({ + message: `Metalink URL 域名不合法: ${metalinkUrl}`, + stdout: "", + stderr: "", + }), + }); + return false; + } + + // 规范化 path:折叠 "." / ".." 段,拒绝越界路径遍历 + const normalizedPath = path.posix.normalize(parsed.pathname); + if (normalizedPath.split("/").includes("..")) { + logger.warn(`addInstallTask invalid metalinkUrl (path traversal): ${metalinkUrl}`); + webContents?.send("install-complete", { + id, + success: false, + time: Date.now(), + exitCode: -1, + message: JSON.stringify({ + message: `Metalink URL 含路径遍历: ${metalinkUrl}`, + stdout: "", + stderr: "", + }), + }); + return false; + } + parsed.pathname = normalizedPath; + // 用规范化后的 URL 继续(去除 "./" 段,与实际请求行为一致) + metalinkUrl = parsed.href; } logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`); - const webContents = event.sender; - // 避免重复添加同一任务(检查 pkgname + origin),但允许重试下载 if (!download.retry) { const existingTask = Array.from(tasks.values()).find( (t) => t.pkgname === pkgname && t.origin === origin, ); if (existingTask) { - webContents.send("install-log", { + webContents?.send("install-log", { id, time: Date.now(), message: `任务 ${pkgname} (${origin}) 已在列表中,忽略重复添加`, }); - webContents.send("install-complete", { + webContents?.send("install-complete", { id, success: false, time: Date.now(), @@ -303,20 +466,25 @@ ipcMain.on("queue-install", async (event, download_json) => { stderr: "", }), }); - return; + return false; } } const superUserCmd = await checkSuperUserCommand(); let execCommand = ""; - const execParams = []; - const downloadDir = `/tmp/spark-store/download/${pkgname}`; + const execParams: string[] = []; + const downloadDir = path.join( + os.tmpdir(), + `spark-store-${process.pid}`, + "download", + pkgname, + ); // APM 应用:若本机没有 apm 命令,通知前端弹窗引导安装 APM if (origin === "apm") { const hasApm = await checkApmAvailable(); if (!hasApm) { - webContents.send("trigger-apm-install-dialog"); - webContents.send("install-complete", { + webContents?.send("trigger-apm-install-dialog"); + webContents?.send("install-complete", { id, success: false, time: Date.now(), @@ -327,7 +495,7 @@ ipcMain.on("queue-install", async (event, download_json) => { stderr: "", }), }); - return; + return false; } } @@ -336,13 +504,29 @@ ipcMain.on("queue-install", async (event, download_json) => { if (superUserCmd) execParams.push(SHELL_CALLER_PATH); if (metalinkUrl && filename) { - execParams.push( - "ssinstall", - `${downloadDir}/${filename}`, - "--delete-after-install", - "--no-create-desktop-entry", - "--native", - ); + const localDeb = `${downloadDir}/${filename}`; + if (forceHeld) { + // 强制安装被系统锁定(hold)的包:合并为单次免密 pkexec(复用商店已有的 + // shell-caller 提权配置),由 shell-caller 的 force-ssinstall 分支内部完成 + // apt-mark unhold → ssinstall 本地 .deb → apt-mark hold,避免单独 pkexec apt-mark + // 触发额外权限框(apt-mark 不在 policykit 免密 exec.path 内)。 + execParams.push( + "force-ssinstall", + pkgname, + localDeb, + "--delete-after-install", + "--no-create-desktop-entry", + ); + } else { + // 本地 .deb 文件模式:已下载的 .deb 通过 ssinstall 安装。 + execParams.push( + "ssinstall", + localDeb, + "--delete-after-install", + "--no-create-desktop-entry", + "--native", + ); + } } else { execParams.push( "ssinstall", @@ -360,7 +544,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 false; + } + execParams.push("ssinstall", path.join(downloadDir, safeFilename)); } else { execParams.push("install", "-y", pkgname); } @@ -379,15 +570,38 @@ ipcMain.on("queue-install", async (event, download_json) => { filename, origin: origin || "apm", upgradeOnly: Boolean(upgradeOnly), + forceHeld: Boolean(forceHeld), phase: metalinkUrl ? "queued-download" : "queued-install", }; tasks.set(id, task); processNextDownload(); processNextInstall(); + return true; +}; + +// Listen for download requests from renderer process +ipcMain.on("queue-install", async (event, download_json) => { + let download: unknown; + try { + download = + typeof download_json === "string" + ? JSON.parse(download_json) + : download_json; + } catch (err) { + logger.error({ err }, "queue-install: invalid JSON payload, ignoring task"); + return; + } + + await addInstallTask(download as QueueInstallPayload, event.sender); }); // Cancel Handler ipcMain.on("cancel-install", (event, id) => { + // 防御性输入校验:id 应为整数,避免 Map 以非预期键查找导致逻辑异常 + if (typeof id !== "number" || !Number.isInteger(id)) { + logger.warn(`cancel-install: invalid id type: ${typeof id}`); + return; + } const task = tasks.get(id); if (!task) return; @@ -420,9 +634,22 @@ ipcMain.on("cancel-install", (event, id) => { const isRunning = task.phase === "downloading" || task.phase === "installing"; if (isRunning) { - // 运行中的任务:终止进程,由对应的阶段处理器在 finally 中清理计数器与队列 - task.download_process?.kill(); - task.install_process?.kill(); + // 运行中的任务:先发 SIGTERM 优雅终止;若 5s 内未退出,降级 SIGKILL 强制杀除, + // 避免子进程忽略 SIGTERM 变成僵尸进程(改进项:进程取消需 SIGKILL 降级处理)。 + const forceKill = (proc: ChildProcess | null, label: string) => { + if (!proc || proc.killed) return; + proc.kill("SIGTERM"); + const pid = proc.pid; + setTimeout(() => { + if (proc && !proc.killed) { + logger.warn(`任务 ${id} 的 ${label} 进程(${pid}) 未响应 SIGTERM,发送 SIGKILL`); + proc.kill("SIGKILL"); + } + }, 5000); + }; + forceKill(task.download_process, "下载"); + forceKill(task.install_process, "安装"); + // 由对应的阶段处理器在 finally 中清理计数器与队列 } else { // 排队中的任务(未开始执行):直接清理并调度 tasks.delete(id); @@ -499,21 +726,31 @@ async function runDownloadPhase(task: InstallTask) { sendLog(`正在获取 Metalink 文件: ${task.metalinkUrl}`); - const response = await axios.get(task.metalinkUrl, { - baseURL: "https://erotica.spark-app.store", - responseType: "stream", - }); + let response: Awaited>; + try { + response = await axios.get(task.metalinkUrl, { + baseURL: "https://erotica.spark-app.store", + responseType: "stream", + }); + } catch (err) { + // Metalink 请求失败(网络/404/超时等):明确回传渲染端,避免 UI 停在"正在获取"后突兀退出 + const reason = err instanceof Error ? err.message : String(err); + logger.error(`Task ${id} Metalink 下载请求失败: ${reason}`); + sendLog(`获取 Metalink 失败: ${reason}`); + throw new Error(`获取 Metalink 失败: ${reason}`); + } const writer = fs.createWriteStream(metalinkPath); response.data.pipe(writer); await new Promise((resolve, reject) => { - writer.on("finish", resolve); + writer.on("finish", () => { + sendLog("Metalink 文件下载完成"); + resolve(); + }); writer.on("error", reject); }); - sendLog("Metalink 文件下载完成"); - // 清理下载目录中的旧文件(保留 .metalink 文件),防止 aria2c 因同名文件卡住 const existingFiles = fs.readdirSync(downloadDir); for (const file of existingFiles) { @@ -627,6 +864,7 @@ async function runDownloadPhase(task: InstallTask) { }); child.on("error", (err) => { clearInterval(timeoutChecker); + sendLog(`aria2c 启动失败: ${err.message}`); reject(err); }); }); @@ -677,6 +915,7 @@ async function runDownloadPhase(task: InstallTask) { /** * 安装阶段:执行安装命令,安装一次只允许一个。 */ + async function runInstallPhase(task: InstallTask) { const { webContents, id } = task; @@ -762,9 +1001,32 @@ async function runInstallPhase(task: InstallTask) { }); // Completion - const success = result.code === 0; + // ssinstall(spark 源)在 dry-run 失败放弃安装时仍可能以退出码 0 结束, + // 需从输出识别明确的失败标志,避免误判为安装成功(否则前端会显示「完成」)。 + let success = result.code === 0; + let failureDetail = ""; + if (success && task.origin === "spark") { + const failMarkers = [ + "放弃安装", + "dry-run测试仍然失败", + "包管理器以错误代码退出", + "Package manager quit with exit code", + ]; + if ( + failMarkers.some( + (marker) => + result.stdout.includes(marker) || result.stderr.includes(marker), + ) + ) { + success = false; + failureDetail = + "ssinstall 放弃安装(可能因软件包被系统锁定 hold,需在更新中心开启「强制安装」)"; + } + } const msgObj = { - message: success ? "安装完成" : `安装失败,退出码 ${result.code}`, + message: success + ? "安装完成" + : `安装失败:${failureDetail || `退出码 ${result.code}`}`, stdout: result.stdout, stderr: result.stderr, }; @@ -815,15 +1077,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 +1150,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 = ""; @@ -1242,55 +1501,55 @@ 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 (_event, payload: any) => { - const pkgname = typeof payload === "string" ? payload : payload.pkgname; - const origin = typeof payload === "string" ? "spark" : payload.origin; +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; - if (!pkgname) { - logger.warn("uninstall-installed missing pkgname"); - return { success: false, message: "missing pkgname" }; - } + 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"; } -// 合法包名字符(Debian 包名规范 + Spark 应用包名常见字符), -// 用于拦截包含特殊字符的非法输入,避免命令注入。 -const PKGNAME_PATTERN = /^[a-zA-Z0-9._+-]+$/; - ipcMain.handle( "launch-app", async ( @@ -1303,7 +1562,8 @@ ipcMain.handle( if ( !pkgname || typeof pkgname !== "string" || - !PKGNAME_PATTERN.test(pkgname) + !PKGNAME_PATTERN.test(pkgname) || + pkgname.length > 256 ) { logger.warn(`Invalid pkgname provided for launch-app: ${pkgname}`); return { success: false, message: "Invalid package name" }; diff --git a/electron/main/backend/update-center/index.ts b/electron/main/backend/update-center/index.ts index 6aa1d3e0..9be9cfee 100644 --- a/electron/main/backend/update-center/index.ts +++ b/electron/main/backend/update-center/index.ts @@ -1,7 +1,10 @@ import { spawn } from "node:child_process"; +import pino from "pino"; import { BrowserWindow, ipcMain } from "electron"; +const logger = pino({ name: "updateCenter" }); + import { SHELL_CALLER_PATH } from "../shared-installer"; import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "../superuser"; import { @@ -165,9 +168,9 @@ const loadAptssItemMetadata = async ( | { item: UpdateCenterItem; warning?: undefined } | { item: null; warning: string } > => { - console.log(`[DEBUG] Loading APTSS metadata for ${item.pkgname}`); + logger.debug(`[DEBUG] Loading APTSS metadata for ${item.pkgname}`); const printUrisCommand = getAptssPrintUrisCommand(item.pkgname); - console.log( + logger.debug( `[DEBUG] APTSS command: ${printUrisCommand.command} ${printUrisCommand.args.join(" ")}`, ); @@ -175,11 +178,11 @@ const loadAptssItemMetadata = async ( printUrisCommand.command, printUrisCommand.args, ); - console.log(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`); - console.log( + logger.debug(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`); + logger.debug( `[DEBUG] APTSS metadata stdout: ${metadataResult.stdout.substring(0, 500)}`, ); - console.log( + logger.debug( `[DEBUG] APTSS metadata stderr: ${metadataResult.stderr.substring(0, 500)}`, ); @@ -188,18 +191,18 @@ const loadAptssItemMetadata = async ( metadataResult, ); if (commandError) { - console.log(`[DEBUG] APTSS metadata error: ${commandError}`); + logger.debug(`[DEBUG] APTSS metadata error: ${commandError}`); return { item: null, warning: commandError }; } const metadata = parsePrintUrisOutput(metadataResult.stdout); if (metadata) { - console.log(`[DEBUG] APTSS parsed metadata:`, { + logger.debug(`[DEBUG] APTSS parsed metadata:`, { ...metadata, downloadUrl: `${metadata.downloadUrl}.metalink`, }); } else { - console.log(`[DEBUG] APTSS parsed metadata:`, metadata); + logger.debug(`[DEBUG] APTSS parsed metadata:`, metadata); } if (!metadata) { @@ -378,7 +381,7 @@ export const loadUpdateCenterItems = async ( storeFilter: StoreFilter = "both", runCommand: UpdateCenterCommandRunner = runCommandCapture, ): Promise => { - console.log( + logger.debug( `[UpdateCenter] loadUpdateCenterItems called with storeFilter=${storeFilter}`, ); const [sparkEnabled, apmEnabled] = await Promise.all([ @@ -389,7 +392,7 @@ export const loadUpdateCenterItems = async ( ? isCommandAvailable(runCommand, "apm") : Promise.resolve(false), ]); - console.log( + logger.debug( `[UpdateCenter] sparkEnabled=${sparkEnabled}, apmEnabled=${apmEnabled}`, ); @@ -415,16 +418,16 @@ export const loadUpdateCenterItems = async ( : Promise.resolve({ code: 0, stdout: "", stderr: "" }), ]); - console.log( + logger.debug( `[UpdateCenter] aptssResult: code=${aptssResult.code}, stdout=${aptssResult.stdout.substring(0, 500)}, stderr=${aptssResult.stderr.substring(0, 500)}`, ); - console.log( + logger.debug( `[UpdateCenter] apmResult: code=${apmResult.code}, stdout=${apmResult.stdout.substring(0, 500)}, stderr=${apmResult.stderr.substring(0, 500)}`, ); - console.log( + logger.debug( `[UpdateCenter] aptssInstalledResult: code=${aptssInstalledResult.code}, stdout=${aptssInstalledResult.stdout.substring(0, 500)}`, ); - console.log( + logger.debug( `[UpdateCenter] apmInstalledResult: code=${apmInstalledResult.code}, stdout=${apmInstalledResult.stdout.substring(0, 500)}`, ); @@ -452,11 +455,11 @@ export const loadUpdateCenterItems = async ( apmEnabled && apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : []; - console.log( + logger.debug( `[UpdateCenter] parsed aptssItems count=${aptssItems.length}`, aptssItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`), ); - console.log( + logger.debug( `[UpdateCenter] parsed apmItems count=${apmItems.length}`, apmItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`), ); @@ -467,7 +470,7 @@ export const loadUpdateCenterItems = async ( : "", apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "", ); - console.log(`[UpdateCenter] installedSources size=${installedSources.size}`); + logger.debug(`[UpdateCenter] installedSources size=${installedSources.size}`); const [categorizedAptssItems, categorizedApmItems] = await Promise.all([ aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]), @@ -481,11 +484,11 @@ export const loadUpdateCenterItems = async ( ? enrichApmItems(categorizedApmItems, runCommand) : Promise.resolve({ items: [], warnings: [] }), ]); - console.log( + logger.debug( `[UpdateCenter] enrichedAptssItems: count=${enrichedAptssItems.items.length}, warnings=${enrichedAptssItems.warnings.length}`, enrichedAptssItems.warnings, ); - console.log( + logger.debug( `[UpdateCenter] enrichedApmItems: count=${enrichedApmItems.items.length}, warnings=${enrichedApmItems.warnings.length}`, enrichedApmItems.warnings, ); @@ -495,15 +498,19 @@ export const loadUpdateCenterItems = async ( enrichItemIcons(enrichedApmItems.items), installedSources, ); - console.log( + logger.debug( `[UpdateCenter] mergedItems count=${mergedItems.length}`, mergedItems.map( (i) => `${i.pkgname} (${i.source}) ${i.currentVersion}->${i.nextVersion}`, ), ); + // 标记被系统锁定(apt-mark hold)的包:这类包被 apt 拒绝升级(除非 --allow-change-held-packages)。 + // 更新中心据此默认禁用其勾选,并提示用户单独开启「强制安装」。 + const heldItems = await markHeldPackages(mergedItems, runCommand); + return { - items: mergedItems, + items: heldItems, warnings: [ ...warnings, ...enrichedAptssItems.warnings, @@ -512,6 +519,167 @@ export const loadUpdateCenterItems = async ( }; }; +// 通过 `apt-mark showhold` 获取系统锁定的包集合,给可升级项中匹配者打 held 标记。 +// 仅 spark(aptss/deb)源受 apt hold 影响;apm 源不经过 apt,无需标记。 +const markHeldPackages = async ( + items: UpdateCenterItem[], + runCommand: UpdateCenterCommandRunner, +): Promise => { + const sparkItems = items.filter((item) => item.source === "aptss"); + if (sparkItems.length === 0) { + return items; + } + + const result = await runCommand("apt-mark", ["showhold"]); + if (result.code !== 0) { + // 查询失败不阻断更新列表,仅跳过 hold 标记 + console.warn(`[UpdateCenter] apt-mark showhold failed: ${result.stderr}`); + return items; + } + + const heldSet = new Set( + result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ); + + if (heldSet.size === 0) { + return items; + } + + return items.map((item) => + item.source === "aptss" && heldSet.has(item.pkgname) + ? { ...item, held: true } + : item, + ); +}; + +// 子进程超时(毫秒):网络慢/镜像源卡死时,避免命令永久挂起 +const SYSTEM_UPDATE_COMMAND_TIMEOUT_MS = 90_000; + +// 带超时保护的命令执行:超时杀掉子进程并 resolve,防止调用方永久冻结 +const runCommandWithTimeout = ( + command: string, + args: string[], +): Promise<{ code: number; stdout: string; stderr: string }> => + new Promise((resolve) => { + const child = spawn(command, args, { shell: false, env: process.env }); + let stdout = ""; + let stderr = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + try { + child.kill("SIGKILL"); + } catch { + // 忽略杀进程异常 + } + resolve({ + code: -1, + stdout, + stderr: `${stderr}\n[timeout] command exceeded ${SYSTEM_UPDATE_COMMAND_TIMEOUT_MS}ms`, + }); + }, SYSTEM_UPDATE_COMMAND_TIMEOUT_MS); + const finish = (result: { + code: number; + stdout: string; + stderr: string; + }): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + child.on("error", (err) => + finish({ code: -1, stdout, stderr: err.message }), + ); + child.on("close", (code) => finish({ code: code ?? -1, stdout, stderr })); + }); + +// 刷新软件源(aptss ssupdate + apm update),提权执行。 +// 供更新中心 IPC 与“启动后空闲预刷新”复用,单一逻辑来源。 +export const runSystemUpdateSources = async ( + storeFilter: StoreFilter = "both", +): Promise<{ aptss?: string; apm?: string }> => { + logger.debug( + `[UpdateCenter] runSystemUpdateSources called with storeFilter=${storeFilter}`, + ); + + const results: { aptss?: string; apm?: string } = {}; + const isSourceEnabled = ( + filter: StoreFilter, + source: "spark" | "apm", + ): boolean => filter === "both" || filter === source; + + if (isSourceEnabled(storeFilter, "spark")) { + const whichResult = await runCommandWithTimeout("which", ["aptss"]); + const aptssAvailable = + whichResult.code === 0 && whichResult.stdout.trim().length > 0; + if (aptssAvailable) { + logger.debug("[UpdateCenter] Running: pkexec shell-caller aptss ssupdate"); + const superUserCmd = await findExecutable( + SUPER_USER_COMMAND_CANDIDATES[0], + ); + if (superUserCmd) { + const result = await runCommandWithTimeout(superUserCmd, [ + SHELL_CALLER_PATH, + "aptss", + "ssupdate", + ]); + results.aptss = + result.code === 0 + ? "ok" + : `failed: ${result.stderr.substring(0, 200)}`; + logger.debug("[UpdateCenter] aptss ssupdate result:", results.aptss); + } else { + results.aptss = "failed: pkexec not found"; + console.warn("[UpdateCenter] pkexec not found, skipping aptss update"); + } + } else { + results.aptss = "skipped: aptss not installed"; + } + } + + if (isSourceEnabled(storeFilter, "apm")) { + const whichResult = await runCommandWithTimeout("which", ["apm"]); + const apmAvailable = + whichResult.code === 0 && whichResult.stdout.trim().length > 0; + if (apmAvailable) { + logger.debug("[UpdateCenter] Running: pkexec shell-caller apm update"); + const superUserCmd = await findExecutable( + SUPER_USER_COMMAND_CANDIDATES[0], + ); + if (superUserCmd) { + const result = await runCommandWithTimeout(superUserCmd, [ + SHELL_CALLER_PATH, + "apm", + "update", + ]); + results.apm = + result.code === 0 + ? "ok" + : `failed: ${result.stderr.substring(0, 200)}`; + logger.debug("[UpdateCenter] apm update result:", results.apm); + } else { + results.apm = "failed: pkexec not found"; + console.warn("[UpdateCenter] pkexec not found, skipping apm update"); + } + } else { + results.apm = "skipped: apm not installed"; + } + } + + return results; +}; + export const registerUpdateCenterIpc = ( ipc: Pick, service: Pick< @@ -528,111 +696,8 @@ export const registerUpdateCenterIpc = ( ): void => { ipc.handle( "update-center-run-system-update", - async (_event, storeFilter: StoreFilter = "both") => { - console.log( - `[UpdateCenter] update-center-run-system-update called with storeFilter=${storeFilter}`, - ); - - const results: { aptss?: string; apm?: string } = {}; - - const runCommand = ( - command: string, - args: string[], - ): Promise<{ code: number; stdout: string; stderr: string }> => - new Promise((resolve) => { - const child = spawn(command, args, { - shell: false, - env: process.env, - }); - let stdout = ""; - let stderr = ""; - child.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - child.stderr?.on("data", (data) => { - stderr += data.toString(); - }); - child.on("error", (err) => - resolve({ code: -1, stdout, stderr: err.message }), - ); - child.on("close", (code) => - resolve({ code: code ?? -1, stdout, stderr }), - ); - }); - - const isSourceEnabled = ( - filter: StoreFilter, - source: "spark" | "apm", - ): boolean => filter === "both" || filter === source; - - // aptss update — 需要提权 - if (isSourceEnabled(storeFilter, "spark")) { - const whichResult = await runCommand("which", ["aptss"]); - const aptssAvailable = - whichResult.code === 0 && whichResult.stdout.trim().length > 0; - if (aptssAvailable) { - console.log( - "[UpdateCenter] Running: pkexec shell-caller aptss ssupdate", - ); - const superUserCmd = await findExecutable( - SUPER_USER_COMMAND_CANDIDATES[0], - ); - if (superUserCmd) { - const result = await runCommand(superUserCmd, [ - SHELL_CALLER_PATH, - "aptss", - "ssupdate", - ]); - results.aptss = - result.code === 0 - ? "ok" - : `failed: ${result.stderr.substring(0, 200)}`; - console.log("[UpdateCenter] aptss ssupdate result:", results.aptss); - } else { - results.aptss = "failed: pkexec not found"; - console.warn( - "[UpdateCenter] pkexec not found, skipping aptss update", - ); - } - } else { - results.aptss = "skipped: aptss not installed"; - } - } - - // apm update — 也需要提权 - if (isSourceEnabled(storeFilter, "apm")) { - const whichResult = await runCommand("which", ["apm"]); - const apmAvailable = - whichResult.code === 0 && whichResult.stdout.trim().length > 0; - if (apmAvailable) { - console.log("[UpdateCenter] Running: pkexec shell-caller apm update"); - const superUserCmd = await findExecutable( - SUPER_USER_COMMAND_CANDIDATES[0], - ); - if (superUserCmd) { - const result = await runCommand(superUserCmd, [ - SHELL_CALLER_PATH, - "apm", - "update", - ]); - results.apm = - result.code === 0 - ? "ok" - : `failed: ${result.stderr.substring(0, 200)}`; - console.log("[UpdateCenter] apm update result:", results.apm); - } else { - results.apm = "failed: pkexec not found"; - console.warn( - "[UpdateCenter] pkexec not found, skipping apm update", - ); - } - } else { - results.apm = "skipped: apm not installed"; - } - } - - return results; - }, + async (_event, storeFilter: StoreFilter = "both") => + runSystemUpdateSources(storeFilter), ); ipc.handle( 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/backend/update-center/service.ts b/electron/main/backend/update-center/service.ts index a05a6069..df3db71a 100644 --- a/electron/main/backend/update-center/service.ts +++ b/electron/main/backend/update-center/service.ts @@ -1,4 +1,8 @@ import { BrowserWindow } from "electron"; +import { + addInstallTask, + type QueueInstallPayload, +} from "../install-manager"; import { IGNORE_CONFIG_PATH, applyIgnoredEntries, @@ -67,6 +71,8 @@ export interface UpdateCenterIgnorePayload { export interface UpdateCenterStartTask { taskKey: string; id: number; + // 强制安装被系统锁定(apt-mark hold)的包 + forceHeld?: boolean; } export interface UpdateCenterService { @@ -115,6 +121,7 @@ const toState = ( migrationSource: item.migrationSource, migrationTarget: item.migrationTarget, aptssVersion: item.aptssVersion, + held: item.held, })), tasks: [], // 不再展示任务日志 warnings: [...snapshot.warnings], @@ -217,15 +224,10 @@ export const createUpdateCenterService = ( }, async start(tasks) { const snapshot = queue.getSnapshot(); - const taskIdByKey = new Map(tasks.map((task) => [task.taskKey, task.id])); - const selectedItems = snapshot.items.filter( - (item) => taskIdByKey.has(getTaskKey(item)) && !item.ignored, + const taskByKey = new Map( + tasks.map((task) => [task.taskKey, task] as const), ); - if (selectedItems.length === 0) { - return; - } - // 获取主窗口的 webContents const mainWindow = BrowserWindow.getAllWindows()[0]; const webContents = mainWindow?.webContents; @@ -235,22 +237,63 @@ export const createUpdateCenterService = ( return; } - // 获取当前 items - let currentItems = snapshot.items; + // 分类:可启动项 vs 被锁定(held)且未开启强制安装的项。 + // 被锁定的项需用户单独开启「强制安装」才能升级,否则明确告知失败,避免静默卡在「开始更新」。 + const startableItems: typeof snapshot.items = []; + const heldBlocked: Array<{ + item: (typeof snapshot.items)[number]; + id: number; + }> = []; - for (const item of selectedItems) { - const updateTaskId = taskIdByKey.get(getTaskKey(item)); - if (!updateTaskId) { + for (const item of snapshot.items) { + const updateTask = taskByKey.get(getTaskKey(item)); + if (!updateTask || item.ignored) continue; + if (item.held === true && !updateTask.forceHeld) { + heldBlocked.push({ item, id: updateTask.id }); continue; } + startableItems.push(item); + } + + // 对「被锁定未强制」的选中项,向前端发送明确失败通知(而非静默跳过) + for (const blocked of heldBlocked) { + webContents.send("install-complete", { + id: blocked.id, + success: false, + time: Date.now(), + exitCode: -1, + message: JSON.stringify({ + message: `软件包 ${blocked.item.pkgname} 被系统锁定(hold),已在更新中心默认跳过。如需升级,请在该软件行开启「强制安装」开关后重试。`, + stdout: "", + stderr: "", + }), + }); + } + + if (startableItems.length === 0) { + return; + } + + // 获取当前 items 的副本,启动成功后从更新中心列表移除已交出的项, + // 避免同一包同时出现在更新中心与下载队列(更新中心只展示待更新的项)。 + let currentItems = snapshot.items; + + for (const item of startableItems) { + const updateTask = taskByKey.get(getTaskKey(item)); + if (!updateTask) { + continue; + } + + const { id: updateTaskId, forceHeld } = updateTask; // 构建 metalink URL const metalinkUrl = item.downloadUrl ? `${item.downloadUrl}.metalink` : undefined; - // 发送到主下载队列 - const installTaskData = { + // 直接加入主下载队列(之前用 webContents.send("queue-install") 只会发给渲染端, + // 主进程 ipcMain 监听不到自己发出的 send,导致任务实际未启动而卡死)。 + const installTaskData: QueueInstallPayload = { id: updateTaskId, pkgname: item.pkgname, metalinkUrl, @@ -258,20 +301,18 @@ export const createUpdateCenterService = ( upgradeOnly: true, origin: item.source === "apm" ? "apm" : "spark", retry: false, + forceHeld: item.held === true ? forceHeld : false, }; - // 通过 IPC 发送到主下载队列 - webContents.send("queue-install", JSON.stringify(installTaskData)); + await addInstallTask(installTaskData, webContents); - // 从更新中心的 items 中移除该应用(不再显示在更新列表中) + // 启动成功后从更新中心列表移除该项(被 hold 未强制拦截的项不会进入此处,仍保留)。 currentItems = currentItems.filter( (i) => getTaskKey(i) !== getTaskKey(item), ); } - // 更新队列中的 items queue.setItems(currentItems); - emit(); }, async cancel(taskKey) { diff --git a/electron/main/backend/update-center/types.ts b/electron/main/backend/update-center/types.ts index 7692befa..c23788ca 100644 --- a/electron/main/backend/update-center/types.ts +++ b/electron/main/backend/update-center/types.ts @@ -24,4 +24,8 @@ export interface UpdateCenterItem { migrationSource?: UpdateSource; migrationTarget?: UpdateSource; aptssVersion?: string; + // 更新发布时间(毫秒时间戳);由上游解析填充,暂无则缺省 + updateTime?: number; + // 是否被系统锁定(apt-mark hold)。被锁定的包默认不可批量选中,需用户单独开启「强制安装」 + held?: boolean; } diff --git a/electron/main/index.ts b/electron/main/index.ts index 7911d5d7..6b514806 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -4,6 +4,7 @@ import { ipcMain, Menu, nativeImage, + net, shell, Tray, nativeTheme, @@ -19,7 +20,10 @@ import { handleCommandLine } from "./deeplink.js"; import { isLoaded } from "../global.js"; import { tasks } from "./backend/install-manager.js"; import { sendTelemetryOnce } from "./backend/telemetry.js"; -import { initializeUpdateCenter } from "./backend/update-center/index.js"; +import { + initializeUpdateCenter, + runSystemUpdateSources, +} from "./backend/update-center/index.js"; import { getMainWindowCloseAction, type MainWindowCloseGuardState, @@ -97,6 +101,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; @@ -272,9 +280,10 @@ const requestApplicationExit = (): void => { app.quit(); }; -const showAndFocusMainWindow = (): void => { +const showAndFocusMainWindow = async (): Promise => { if (!win || win.isDestroyed()) { - createWindow(); + // 等待窗口创建完成,创建失败时调用方可通过异常感知 + await createWindow(); return; } @@ -289,6 +298,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; @@ -305,21 +316,22 @@ function getWindowStatePath(): string { // 校验保存的窗口位置是否至少部分落在某个显示器可见区域内,避免窗口跑到屏幕外 function isVisible(bounds: WindowState): boolean { + // 解构为局部常量后,控制流收窄(const 不可变)可穿透到下方嵌套闭包, + // 消除 x/y/width/height 的 “可能为未定义” 告警 + const { x, y, width, height } = bounds; if ( - bounds.width === undefined || - bounds.height === undefined || - bounds.x === undefined || - bounds.y === undefined + 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; }); } @@ -328,9 +340,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 && @@ -358,6 +368,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(() => { @@ -373,14 +393,34 @@ function scheduleSaveBounds(winInstance: BrowserWindow): void { }, 400); } +// 应用退出前立即持久化(防抖 400ms 可能在快速关闭时丢失最后一次状态) +app.on("before-quit", () => { + flushSaveBounds(); +}); + 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 +438,16 @@ async function createWindow() { }); win = mainWindow; - // 恢复上一次的最大化状态 - if (saved.maximized) { - mainWindow.maximize(); - } - logger.info({ saved }, "已恢复窗口状态"); + // 设计意图(非缺陷,勿改为自动恢复 maximized): + // 启动时不自动恢复最大化状态。原因——最大化窗口在多数屏幕上 bounds 会超过 + // OVERSIZED_WINDOW_THRESHOLD(1600x900),若强行恢复最大化会导致「启动即全屏、 + // 还原按钮失效」的体验问题;故最大化/全屏状态在关闭后统一回退为默认尺寸并居中, + // 放大/还原交由标题栏按钮由用户主动控制。WindowState 仍保存 maximized 字段 + // (供需要该信息的场景读取),但恢复阶段有意不调用 win.maximize()。 + logger.info( + { saved, restoredWidth, restoredHeight, oversized }, + "已恢复窗口状态(过大窗口回退默认尺寸并居中;最大化状态有意不自动恢复)", + ); // 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复 // 位置/最大化变化由主进程事件保存;尺寸变化由渲染端 DOM resize 经 IPC 兜底保存 @@ -428,9 +473,26 @@ async function createWindow() { logger.info("Renderer process is ready."); }); - // Make all links open with the browser, not with the application + // 仅允许可信域名的 https 链接通过浏览器打开,避免钓鱼/恶意站。 + // 协议前缀 + 域名后缀白名单双重校验;非法/无效 URL 一律拒绝。 + const ALLOWED_EXTERNAL_HOSTS = [ + "spark-app.store", + "gitee.com", + "bbs.spark-app.store", + "spark-app.cn", + ]; mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith("https:")) shell.openExternal(url); + try { + const parsed = new URL(url); + if ( + parsed.protocol === "https:" && + ALLOWED_EXTERNAL_HOSTS.some((h) => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`)) + ) { + shell.openExternal(url); + } + } catch { + // 无效 URL,拒绝打开 + } return { action: "deny" }; }); // win.webContents.on('will-navigate', (event, url) => { }) #344 @@ -444,6 +506,8 @@ async function createWindow() { mainWindow.on("close", (event) => { if (allowAppExit) { // 真正退出前同步保存最终窗口尺寸(防抖可能尚未触发) + // 先清除尚未触发的防抖定时器,避免旧定时器随后覆盖本次同步写入 + if (saveBoundsTimer) clearTimeout(saveBoundsTimer); const { width, height, x, y } = mainWindow.getBounds(); saveWindowState({ width, @@ -613,6 +677,15 @@ app.whenReady().then(() => { // Set User-Agent for client session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { details.requestHeaders["User-Agent"] = getUserAgent(); + // 数据 JSON(applist / categories / priority-config / sidebar-config 等)禁用客户端缓存。 + // nginx 默认不发 Cache-Control,Chromium 会按启发式 TTL(≈(now-LastModified)/10)复用陈旧副本, + // 导致服务端上新应用后商店内仍搜不到、且重启无效(仅删 Cache 目录才生效)。 + // 注意:C2 会给 URL 追加 ?_t=... 版本戳,故需按 query 前的 pathname 判断,而非 endsWith(".json")。 + const dataUrlPath = details.url.split("?")[0]; + if (dataUrlPath.endsWith(".json")) { + details.requestHeaders["Cache-Control"] = "no-cache"; + details.requestHeaders["Pragma"] = "no-cache"; + } callback({ cancel: false, requestHeaders: details.requestHeaders }); }); createWindow(); @@ -620,8 +693,68 @@ app.whenReady().then(() => { initializeUpdateCenter(); // 启动后执行一次遥测(仅 Linux,不阻塞) sendTelemetryOnce(getAppVersion()); + + // 注册“渲染进程首页加载完成”信号:收到后立即开始后台刷新软件源, + // 趁系统负载不高时提前刷新 aptss/apm 源,用户稍后打开“软件更新”即可秒出。 + // 同时保留一个兜底定时器,防止渲染进程未发信号时完全不刷新。 + ipcMain.on("update-center-trigger-prefetch", () => { + startSourcePreRefreshOnce(); + }); + setTimeout(startSourcePreRefreshOnce, PRE_REFRESH_FALLBACK_MS); }); +// 启动后空闲预刷新软件源(带重试),不阻塞启动流程 +// 与 src/modules/updateCenter.ts 的 backgroundRefresh 重试为【对称设计】,非代码遗漏: +// 主进程负责“启动预热”,前端负责“打开兜底”, +// 两者进程/守卫/调用目标不同,故各自保留一份,勿抽共享。 +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; + +// 按重试次数取退避毫秒(越界时回退到最大间隔) +const getPreRefreshBackoffDelay = (attempt: number): number => + PRE_REFRESH_BACKOFF_MS[attempt - 1] ?? + PRE_REFRESH_BACKOFF_MS[PRE_REFRESH_BACKOFF_MS.length - 1]; + +// 确保预刷新只触发一次(渲染信号或兜底定时器 whichever first)。 +// 设计意图:单次会话仅预热一次(preRefreshStarted 置 true 后不再重置), +// 避免用户在更新中心 Tab 间快速切换时重复触发 pkexec 弹窗造成困惑。 +const startSourcePreRefreshOnce = (attempt = 1): void => { + if (preRefreshStarted) return; + preRefreshStarted = true; + + const run = (): void => { + // 网络可用性前置检查:离线时不发起提权刷新(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); + }) + .catch((error) => { + console.warn("[UpdateCenter] pre-refresh failed:", error); + if (attempt < PRE_REFRESH_MAX_RETRIES) { + const delay = getPreRefreshBackoffDelay(attempt); + setTimeout(() => startSourcePreRefreshOnce(attempt + 1), delay); + } + }); + }; + + run(); +}; + app.on("window-all-closed", () => { win = null; allowAppExit = false; @@ -629,17 +762,17 @@ app.on("window-all-closed", () => { }); app.on("second-instance", () => { - showAndFocusMainWindow(); + void showAndFocusMainWindow(); }); app.on("activate", () => { - showAndFocusMainWindow(); + void showAndFocusMainWindow(); }); 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"); }); @@ -686,7 +819,7 @@ app.whenReady().then(() => { { label: "显示主界面", click: () => { - showAndFocusMainWindow(); + void showAndFocusMainWindow(); }, }, { @@ -705,7 +838,7 @@ app.whenReady().then(() => { win.hide(); win.setSkipTaskbar(true); } else { - showAndFocusMainWindow(); + void showAndFocusMainWindow(); } }); }); diff --git a/extras/shell-caller.sh b/extras/shell-caller.sh index f9cb5921..4f535a3e 100755 --- a/extras/shell-caller.sh +++ b/extras/shell-caller.sh @@ -85,6 +85,32 @@ case "$command_type" in fi ;; + "force-ssinstall") + # 强制安装被系统锁定(apt-mark hold)的包:在一次已提权的 pkexec 会话内 + # 完成「解除锁定 → ssinstall 本地 .deb → 恢复锁定」,避免单独 pkexec apt-mark + # 触发额外权限框(apt-mark 不在 policykit 免密 exec.path 内)。 + # 用法:force-ssinstall [ssinstall 额外参数...] + pkg="$2" + deb="$3" + shift 3 + # 仅放行单包名(与前端 PKGNAME_PATTERN 一致,双引号防注入) + if [[ -z "$pkg" || -z "$deb" ]]; then + echo "错误:force-ssinstall 缺少包名或 deb 路径参数。" + exit 1 + fi + # 安装前解除系统锁定(失败仅警告,不阻断) + apt-mark unhold "$pkg" 2>&1 || echo "警告:解除系统锁定 $pkg 失败,将继续尝试安装" + # 安装本地 .deb(--native 由本分支统一追加) + /usr/bin/ssinstall "$deb" "$@" --native 2>&1 + exit_code=$? + # 安装后无论如何恢复系统锁定,保持用户原本的 hold 状态 + apt-mark hold "$pkg" 2>&1 || echo "警告:恢复系统锁定 $pkg 失败,请手动检查" + if [[ "$exit_code" != "0" ]]; then + echo "安装失败,可尝试安装对应的 APM 版本应用;若无对应的 APM 版本应用,可提交用户反馈" + fi + exit $exit_code + ;; + "aptss") # 针对 aptss 的特殊逻辑:如果是 remove 子命令,需要图形化确认 if [[ "$2" == "remove" ]]; then @@ -158,7 +184,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/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..abd3b205 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,7 +4,17 @@ 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 +56,6 @@
-
- -
+ + @@ -181,7 +199,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" @@ -202,6 +220,7 @@ diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index a6820f41..8db4000a 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -1,51 +1,5 @@ - +// 下载量格式化:>= 1万 显示为 "x.x万" +// (download count now formatted inside AppCard via its downloadCount prop) + diff --git a/src/components/update-center/UpdateCenterItem.vue b/src/components/update-center/UpdateCenterItem.vue index 13ad0bc6..d7b81018 100644 --- a/src/components/update-center/UpdateCenterItem.vue +++ b/src/components/update-center/UpdateCenterItem.vue @@ -1,100 +1,153 @@ @@ -111,6 +164,8 @@ const props = defineProps<{ item: UpdateCenterItem; task?: UpdateCenterTaskState; selected: boolean; + // 是否已开启强制安装(仅对 held 项有意义) + forced?: boolean; }>(); const PLACEHOLDER_ICON = @@ -121,8 +176,14 @@ defineEmits<{ (e: "toggle-selection"): void; (e: "ignore-item"): void; (e: "unignore-item"): void; + (e: "toggle-force"): void; }>(); +// 被锁定且未强制时,复选框禁用(与「已忽略」一样不可被选中) +const isSelectionLocked = computed( + () => props.item.ignored === true || (props.item.held === true && !props.forced), +); + const normalizeIconSrc = (icon: string): string => { if (/^[a-z]+:\/\//i.test(icon)) { return icon; @@ -185,4 +246,27 @@ const showProgress = computed(() => { const progressText = computed(() => `${props.task?.progress ?? 0}%`); const progressStyle = computed(() => ({ width: progressText.value })); + +// 相对时间(如「3天前」),无数据降级为 「—」 +const timeLabel = computed(() => { + const t = props.item.updateTime; + if (!t || typeof t !== "number") return "—"; + const diff = Date.now() - t; + const day = 24 * 60 * 60 * 1000; + if (diff < 0) return "刚刚"; + if (diff < day) return "今天"; + if (diff < 2 * day) return "昨天"; + if (diff < 30 * day) return `${Math.floor(diff / day)}天前`; + if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月前`; + return `${Math.floor(diff / (365 * day))}年前`; +}); + +// 大小格式化(如 45 MB / 1.2 GB) +const sizeLabel = computed(() => { + const size = props.item.size; + if (!size || size <= 0) return "—"; + const mb = size / (1024 * 1024); + if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; + return `${Math.round(mb)} MB`; +}); diff --git a/src/components/update-center/UpdateCenterList.vue b/src/components/update-center/UpdateCenterList.vue index 68b10e5b..2ac203a5 100644 --- a/src/components/update-center/UpdateCenterList.vue +++ b/src/components/update-center/UpdateCenterList.vue @@ -10,12 +10,14 @@
; + forcedTaskKeys: Set; + apps: App[]; }>(); defineEmits<{ (e: "toggle-selection", taskKey: string): void; + (e: "toggle-force", taskKey: string): void; (e: "ignore-item", packageName: string, newVersion: string): void; (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/components/update-center/UpdateCenterToolbar.vue b/src/components/update-center/UpdateCenterToolbar.vue index acf9aa18..7b6c9d50 100644 --- a/src/components/update-center/UpdateCenterToolbar.vue +++ b/src/components/update-center/UpdateCenterToolbar.vue @@ -11,7 +11,7 @@ 集中管理 APM 与传统 deb 更新任务

-
+
+ + + + +
-
- - - 已选 {{ selectedCount }} 项 - -
-