feat(update-center): 支持被系统锁定(hold)包的强制安装与更新中心体验优化

- 标记 apt-mark hold 的 spark(aptss) 源包,默认禁用勾选并提示开启强制安装
- 更新中心发起更新时透传 forceHeld,后端 Spark 本地 deb 走 shell-caller
  force-ssinstall 分支(单次免密 pkexec:unhold→ssinstall→hold),避免二次提权
- 无可选项时禁用全选并提示"无可更新项";更新选中后从列表中移除已启动项
- DownloadDetail 紧凑布局避免整页滚动;Toolbar 全选与更新选中按钮同行
- 继承 PKGNAME_PATTERN 与 metalinkUrl 白名单(https+*.spark-app.store+拒..)纵深防御
- apm 源无 hold 概念,forceHeld 在 apm 分支忽略(正确行为)
This commit is contained in:
xiyidaiwa
2026-08-13 16:42:21 +08:00
parent b5c5ab04b5
commit 0f62a7e36a
17 changed files with 893 additions and 105 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
spark-store (5.2.1.24-test) UNRELEASED; urgency=medium spark-store (5.2.1.0) UNRELEASED; urgency=medium
* Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP> * Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP>
@@ -0,0 +1,2 @@
1、将标题栏变宽一点,现在加入了搜索栏,太窄了不好看
2、在贡献排行中,点击对应的贡献用户,可以查看用户下所有的应用(可能需要模糊查询)
+178
View File
@@ -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 [选项] <deb路径>`,选项仅自身专用),会被其透传给内部 `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 <deb> -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 <hold|unhold> 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 <pkg>``ssinstall <deb> [额外参数] --native``apt-mark hold <pkg>`;unhold 失败仅警告不阻断,hold 无论成败都恢复;参数用双引号防注入、空参校验。
- `install-manager.ts``forceHeld && origin==='spark' && 本地 .deb` 时,构造命令走 `shell-caller force-ssinstall <pkg> <deb> --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 明确显示「安装失败」而非「下载完成」
+168 -20
View File
@@ -58,6 +58,8 @@ export interface QueueInstallPayload {
origin?: "spark" | "apm"; origin?: "spark" | "apm";
upgradeOnly?: boolean; upgradeOnly?: boolean;
retry?: boolean; retry?: boolean;
// 强制安装被系统锁定(apt-mark hold)的包:spark 源安装时追加 --allow-change-held-packages
forceHeld?: boolean;
} }
type InstallTask = { type InstallTask = {
@@ -74,6 +76,7 @@ type InstallTask = {
origin: "spark" | "apm"; origin: "spark" | "apm";
upgradeOnly?: boolean; upgradeOnly?: boolean;
cancelled?: boolean; cancelled?: boolean;
forceHeld?: boolean;
phase: "queued-download" | "downloading" | "queued-install" | "installing"; phase: "queued-download" | "downloading" | "queued-install" | "installing";
}; };
@@ -302,41 +305,145 @@ export const addInstallTask = async (
download: QueueInstallPayload, download: QueueInstallPayload,
sender: WebContents | null, sender: WebContents | null,
): Promise<boolean> => { ): Promise<boolean> => {
const { id, pkgname, metalinkUrl, filename, origin, upgradeOnly } = download; const webContents = sender;
const { id, pkgname, filename, origin, upgradeOnly, forceHeld } = download;
// metalinkUrl 可能在下方校验时被规范化(折叠 ./ 段),故用 let
let { metalinkUrl } = download;
if (!id || !pkgname) { if (!id || !pkgname) {
logger.warn("addInstallTask: passed arguments missing id or pkgname"); 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; return false;
} }
// 包名/文件名白名单校验:防止路径遍历(如 ../../)或非法字符进入下载目录与安装命令构建 // 包名/文件名白名单校验:防止路径遍历(如 ../../)或非法字符进入下载目录与安装命令构建
if (!PKGNAME_PATTERN.test(pkgname)) { if (!PKGNAME_PATTERN.test(pkgname)) {
logger.warn(`addInstallTask invalid pkgname: ${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; return false;
} }
if (filename && !PKGNAME_PATTERN.test(filename)) { if (filename && !PKGNAME_PATTERN.test(filename)) {
logger.warn(`addInstallTask invalid filename: ${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; return false;
} }
// metalinkUrl 来自渲染端(由目录 filename / 主进程解析的 downloadUrl 拼成)。 // metalinkUrl 来自渲染端(由目录 filename / 主进程解析的 downloadUrl 拼成)。
// 纵深防御:仅允许相对路径(拼接 baseURL)或以官方域名开头的绝对 URL // 纵深防御:仅允许 https 协议、官方域名(含任意子域 *.spark-app.store的绝对 URL
// 拒绝任意外部地址,防止 axios 忽略 baseURL 发起 SSRF/任意源下载 // 或以 "/" 开头的相对路径(后续由 axios baseURL 拼接官方域名)
// 先解析并规范化 path 中的 "./" 段(浏览器/Node 实际请求时会自动折叠,此处显式处理,
// 避免把合法的 "./" 误判为非法),再拒绝 ".." 路径遍历越界与外部域名(防 SSRF)。
if (metalinkUrl) { if (metalinkUrl) {
const isRelative = metalinkUrl.startsWith("/"); let parsed: URL;
const isOfficial = metalinkUrl.startsWith( try {
"https://erotica.spark-app.store", // 相对路径以官方域为基进行解析
); parsed = new URL(metalinkUrl, "https://erotica.spark-app.store");
if (!isRelative && !isOfficial) { } catch {
logger.warn(`addInstallTask invalid metalinkUrl: ${metalinkUrl}`); 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; 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}`); logger.info(`收到下载任务: ${id}, 软件包名称: ${pkgname}, 来源: ${origin}`);
const webContents = sender;
// 避免重复添加同一任务(检查 pkgname + origin),但允许重试下载 // 避免重复添加同一任务(检查 pkgname + origin),但允许重试下载
if (!download.retry) { if (!download.retry) {
const existingTask = Array.from(tasks.values()).find( const existingTask = Array.from(tasks.values()).find(
@@ -397,13 +504,29 @@ export const addInstallTask = async (
if (superUserCmd) execParams.push(SHELL_CALLER_PATH); if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
if (metalinkUrl && filename) { if (metalinkUrl && filename) {
execParams.push( const localDeb = `${downloadDir}/${filename}`;
"ssinstall", if (forceHeld) {
`${downloadDir}/${filename}`, // 强制安装被系统锁定(hold)的包:合并为单次免密 pkexec(复用商店已有的
"--delete-after-install", // shell-caller 提权配置),由 shell-caller 的 force-ssinstall 分支内部完成
"--no-create-desktop-entry", // apt-mark unhold → ssinstall 本地 .deb → apt-mark hold,避免单独 pkexec apt-mark
"--native", // 触发额外权限框(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 { } else {
execParams.push( execParams.push(
"ssinstall", "ssinstall",
@@ -447,6 +570,7 @@ export const addInstallTask = async (
filename, filename,
origin: origin || "apm", origin: origin || "apm",
upgradeOnly: Boolean(upgradeOnly), upgradeOnly: Boolean(upgradeOnly),
forceHeld: Boolean(forceHeld),
phase: metalinkUrl ? "queued-download" : "queued-install", phase: metalinkUrl ? "queued-download" : "queued-install",
}; };
tasks.set(id, task); tasks.set(id, task);
@@ -773,6 +897,7 @@ async function runDownloadPhase(task: InstallTask) {
/** /**
* 安装阶段:执行安装命令,安装一次只允许一个。 * 安装阶段:执行安装命令,安装一次只允许一个。
*/ */
async function runInstallPhase(task: InstallTask) { async function runInstallPhase(task: InstallTask) {
const { webContents, id } = task; const { webContents, id } = task;
@@ -858,9 +983,32 @@ async function runInstallPhase(task: InstallTask) {
}); });
// Completion // Completion
const success = result.code === 0; // ssinstallspark 源)在 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 = { const msgObj = {
message: success ? "安装完成" : `安装失败,退出码 ${result.code}`, message: success
? "安装完成"
: `安装失败:${failureDetail || `退出码 ${result.code}`}`,
stdout: result.stdout, stdout: result.stdout,
stderr: result.stderr, stderr: result.stderr,
}; };
+41 -1
View File
@@ -502,8 +502,12 @@ export const loadUpdateCenterItems = async (
), ),
); );
// 标记被系统锁定(apt-mark hold)的包:这类包被 apt 拒绝升级(除非 --allow-change-held-packages)。
// 更新中心据此默认禁用其勾选,并提示用户单独开启「强制安装」。
const heldItems = await markHeldPackages(mergedItems, runCommand);
return { return {
items: mergedItems, items: heldItems,
warnings: [ warnings: [
...warnings, ...warnings,
...enrichedAptssItems.warnings, ...enrichedAptssItems.warnings,
@@ -512,6 +516,42 @@ export const loadUpdateCenterItems = async (
}; };
}; };
// 通过 `apt-mark showhold` 获取系统锁定的包集合,给可升级项中匹配者打 held 标记。
// 仅 sparkaptss/deb)源受 apt hold 影响;apm 源不经过 apt,无需标记。
const markHeldPackages = async (
items: UpdateCenterItem[],
runCommand: UpdateCenterCommandRunner,
): Promise<UpdateCenterItem[]> => {
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; const SYSTEM_UPDATE_COMMAND_TIMEOUT_MS = 90_000;
+52 -15
View File
@@ -71,6 +71,8 @@ export interface UpdateCenterIgnorePayload {
export interface UpdateCenterStartTask { export interface UpdateCenterStartTask {
taskKey: string; taskKey: string;
id: number; id: number;
// 强制安装被系统锁定(apt-mark hold)的包
forceHeld?: boolean;
} }
export interface UpdateCenterService { export interface UpdateCenterService {
@@ -119,6 +121,7 @@ const toState = (
migrationSource: item.migrationSource, migrationSource: item.migrationSource,
migrationTarget: item.migrationTarget, migrationTarget: item.migrationTarget,
aptssVersion: item.aptssVersion, aptssVersion: item.aptssVersion,
held: item.held,
})), })),
tasks: [], // 不再展示任务日志 tasks: [], // 不再展示任务日志
warnings: [...snapshot.warnings], warnings: [...snapshot.warnings],
@@ -221,15 +224,10 @@ export const createUpdateCenterService = (
}, },
async start(tasks) { async start(tasks) {
const snapshot = queue.getSnapshot(); const snapshot = queue.getSnapshot();
const taskIdByKey = new Map(tasks.map((task) => [task.taskKey, task.id])); const taskByKey = new Map(
const selectedItems = snapshot.items.filter( tasks.map((task) => [task.taskKey, task] as const),
(item) => taskIdByKey.has(getTaskKey(item)) && !item.ignored,
); );
if (selectedItems.length === 0) {
return;
}
// 获取主窗口的 webContents // 获取主窗口的 webContents
const mainWindow = BrowserWindow.getAllWindows()[0]; const mainWindow = BrowserWindow.getAllWindows()[0];
const webContents = mainWindow?.webContents; const webContents = mainWindow?.webContents;
@@ -239,14 +237,54 @@ export const createUpdateCenterService = (
return; return;
} }
// 获取当前 items // 分类:可启动项 vs 被锁定(held)且未开启强制安装的项。
let currentItems = snapshot.items; // 被锁定的项需用户单独开启「强制安装」才能升级,否则明确告知失败,避免静默卡在「开始更新」。
const startableItems: typeof snapshot.items = [];
const heldBlocked: Array<{
item: (typeof snapshot.items)[number];
id: number;
}> = [];
for (const item of selectedItems) { for (const item of snapshot.items) {
const updateTaskId = taskIdByKey.get(getTaskKey(item)); const updateTask = taskByKey.get(getTaskKey(item));
if (!updateTaskId) { if (!updateTask || item.ignored) continue;
if (item.held === true && !updateTask.forceHeld) {
heldBlocked.push({ item, id: updateTask.id });
continue; 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 // 构建 metalink URL
const metalinkUrl = item.downloadUrl const metalinkUrl = item.downloadUrl
@@ -263,19 +301,18 @@ export const createUpdateCenterService = (
upgradeOnly: true, upgradeOnly: true,
origin: item.source === "apm" ? "apm" : "spark", origin: item.source === "apm" ? "apm" : "spark",
retry: false, retry: false,
forceHeld: item.held === true ? forceHeld : false,
}; };
await addInstallTask(installTaskData, webContents); await addInstallTask(installTaskData, webContents);
// 从更新中心的 items 中移除该应用(不再显示在更新列表中) // 启动成功后从更新中心列表移除该项(被 hold 未强制拦截的项不会进入此处,仍保留)。
currentItems = currentItems.filter( currentItems = currentItems.filter(
(i) => getTaskKey(i) !== getTaskKey(item), (i) => getTaskKey(i) !== getTaskKey(item),
); );
} }
// 更新队列中的 items
queue.setItems(currentItems); queue.setItems(currentItems);
emit(); emit();
}, },
async cancel(taskKey) { async cancel(taskKey) {
@@ -26,4 +26,6 @@ export interface UpdateCenterItem {
aptssVersion?: string; aptssVersion?: string;
// 更新发布时间(毫秒时间戳);由上游解析填充,暂无则缺省 // 更新发布时间(毫秒时间戳);由上游解析填充,暂无则缺省
updateTime?: number; updateTime?: number;
// 是否被系统锁定(apt-mark hold)。被锁定的包默认不可批量选中,需用户单独开启「强制安装」
held?: boolean;
} }
+26
View File
@@ -85,6 +85,32 @@ case "$command_type" in
fi fi
;; ;;
"force-ssinstall")
# 强制安装被系统锁定(apt-mark hold)的包:在一次已提权的 pkexec 会话内
# 完成「解除锁定 → ssinstall 本地 .deb → 恢复锁定」,避免单独 pkexec apt-mark
# 触发额外权限框(apt-mark 不在 policykit 免密 exec.path 内)。
# 用法:force-ssinstall <pkgname> <deb路径> [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")
# 针对 aptss 的特殊逻辑:如果是 remove 子命令,需要图形化确认 # 针对 aptss 的特殊逻辑:如果是 remove 子命令,需要图形化确认
if [[ "$2" == "remove" ]]; then if [[ "$2" == "remove" ]]; then
+42 -33
View File
@@ -9,38 +9,41 @@
> >
<div <div
v-if="show" v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 px-4 py-6"
@click="handleOverlayClick" @click="handleOverlayClick"
@wheel="onOverlayWheel" @wheel="onOverlayWheel"
> >
<div <div
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent w-full max-w-2xl max-h-[85vh] overflow-y-auto overscroll-contain rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900" class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
@click.stop @click.stop
> >
<div class="flex items-start justify-between"> <div class="flex items-start justify-between p-5 pb-3">
<div> <div>
<p class="text-2xl font-semibold text-slate-900 dark:text-white"> <p class="text-xl font-semibold text-slate-900 dark:text-white">
下载详情 下载详情
</p> </p>
<p class="text-sm text-slate-500 dark:text-slate-400"> <p class="text-xs text-slate-500 dark:text-slate-400">
实时了解安装进度 实时了解安装进度
</p> </p>
</div> </div>
<button <button
type="button" type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/60 text-slate-500 transition hover:text-slate-900 dark:border-slate-700" class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/60 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
@click="close" @click="close"
> >
<i class="fas fa-xmark"></i> <i class="fas fa-xmark"></i>
</button> </button>
</div> </div>
<div v-if="download" class="mt-6 space-y-6"> <div
v-if="download"
class="scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 scrollbar-track-transparent space-y-3 overflow-y-auto overscroll-contain px-5 pb-5"
>
<div <div
class="flex items-center gap-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="flex items-center gap-3 rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
> >
<div <div
class="h-16 w-16 overflow-hidden rounded-2xl bg-slate-100 dark:bg-slate-800" class="h-12 w-12 shrink-0 overflow-hidden rounded-2xl bg-slate-100 dark:bg-slate-800"
> >
<img <img
:src="download.icon" :src="download.icon"
@@ -48,18 +51,18 @@
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
</div> </div>
<div class="flex-1"> <div class="min-w-0 flex-1">
<p class="text-lg font-semibold text-slate-900 dark:text-white"> <p class="truncate text-base font-semibold text-slate-900 dark:text-white">
{{ download.name }} {{ download.name }}
</p> </p>
<p class="text-sm text-slate-500 dark:text-slate-400"> <p class="truncate text-xs text-slate-500 dark:text-slate-400">
{{ download.pkgname }} · {{ download.version }} {{ download.pkgname }} · {{ download.version }}
</p> </p>
</div> </div>
</div> </div>
<div <div
class="space-y-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="space-y-3 rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm font-medium text-slate-500">状态</span> <span class="text-sm font-medium text-slate-500">状态</span>
@@ -80,7 +83,7 @@
</span> </span>
</div> </div>
<div v-if="download.status === 'downloading'" class="space-y-3"> <div v-if="download.status === 'downloading'" class="space-y-2">
<div class="h-2 rounded-full bg-slate-100 dark:bg-slate-800"> <div class="h-2 rounded-full bg-slate-100 dark:bg-slate-800">
<div <div
class="h-full rounded-full bg-brand" class="h-full rounded-full bg-brand"
@@ -112,36 +115,42 @@
</div> </div>
<div <div
class="rounded-2xl border border-slate-200/60 p-4 text-sm text-slate-600 dark:border-slate-800/60 dark:text-slate-300" class="grid grid-cols-2 gap-x-4 gap-y-1 rounded-2xl border border-slate-200/60 p-3 text-sm text-slate-600 dark:border-slate-800/60 dark:text-slate-300"
> >
<div class="flex justify-between py-1"> <div class="flex justify-between gap-2">
<span class="text-slate-400">下载源</span> <span class="shrink-0 text-slate-400">下载源</span>
<span class="font-medium text-slate-900 dark:text-white">{{ <span class="truncate font-medium text-slate-900 dark:text-white">{{
download.origin === "spark" ? "Spark Store" : "APM Store" download.origin === "spark" ? "Spark Store" : "APM Store"
}}</span> }}</span>
</div> </div>
<div v-if="download.startTime" class="flex justify-between py-1"> <div
<span class="text-slate-400">开始时间</span> v-if="download.startTime"
<span>{{ formatDate(download.startTime) }}</span> class="flex justify-between gap-2"
>
<span class="shrink-0 text-slate-400">开始时间</span>
<span class="truncate">{{ formatDate(download.startTime) }}</span>
</div> </div>
<div v-if="download.endTime" class="flex justify-between py-1"> <div
<span class="text-slate-400">完成时间</span> v-if="download.endTime"
<span>{{ formatDate(download.endTime) }}</span> class="flex justify-between gap-2"
>
<span class="shrink-0 text-slate-400">完成时间</span>
<span class="truncate">{{ formatDate(download.endTime) }}</span>
</div> </div>
<div <div
v-if="download.error" v-if="download.error"
class="flex justify-between py-1 text-rose-500" class="col-span-2 flex justify-between gap-2 text-rose-500"
> >
<span>错误信息</span> <span class="shrink-0">错误信息</span>
<span class="text-right">{{ download.error }}</span> <span class="truncate text-right">{{ download.error }}</span>
</div> </div>
</div> </div>
<div <div
v-if="download.logs && download.logs.length" v-if="download.logs && download.logs.length"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
> >
<div class="mb-3 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
<span class="font-semibold text-slate-800 dark:text-slate-100" <span class="font-semibold text-slate-800 dark:text-slate-100"
>下载日志</span >下载日志</span
> >
@@ -155,22 +164,22 @@
</button> </button>
</div> </div>
<div <div
class="max-h-48 space-y-2 overflow-y-auto overscroll-contain rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300" class="max-h-32 space-y-1.5 overflow-y-auto overscroll-contain rounded-2xl bg-slate-50/80 p-3 font-mono text-xs text-slate-600 dark:bg-slate-900/60 dark:text-slate-300"
> >
<div <div
v-for="(log, index) in download.logs" v-for="(log, index) in download.logs"
:key="index" :key="index"
class="flex gap-3" class="flex gap-3"
> >
<span class="text-slate-400">{{ <span class="shrink-0 text-slate-400">{{
formatLogTime(log.time) formatLogTime(log.time)
}}</span> }}</span>
<span>{{ log.message }}</span> <span class="break-all">{{ log.message }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-wrap justify-end gap-3"> <div class="flex flex-wrap justify-end gap-3 pt-1">
<button <button
v-if=" v-if="
download.status === 'downloading' || download.status === 'downloading' ||
+21
View File
@@ -20,6 +20,7 @@
<UpdateCenterToolbar <UpdateCenterToolbar
:search-query="store.searchQuery.value" :search-query="store.searchQuery.value"
:selected-count="selectedCount" :selected-count="selectedCount"
:selectable-count="store.selectableCount.value"
:all-selected="store.allSelected.value" :all-selected="store.allSelected.value"
:some-selected="store.someSelected.value" :some-selected="store.someSelected.value"
:loading="store.loading.value" :loading="store.loading.value"
@@ -61,13 +62,24 @@
> >
正在刷新更新列表 正在刷新更新列表
</div> </div>
<div
v-if="heldLockedCount > 0"
class="mx-6 mt-4 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
>
<p class="leading-6">
检测到 {{ heldLockedCount }} 个软件被系统锁定hold默认不会升级如需强制升级请在对应软件上打开强制安装开关
</p>
</div>
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<UpdateCenterList <UpdateCenterList
:items="store.filteredItems.value" :items="store.filteredItems.value"
:tasks="store.snapshot.value.tasks" :tasks="store.snapshot.value.tasks"
:selected-task-keys="store.selectedTaskKeys.value" :selected-task-keys="store.selectedTaskKeys.value"
:forced-task-keys="store.forcedTaskKeys.value"
:apps="apps" :apps="apps"
@toggle-selection="emit('toggle-selection', $event)" @toggle-selection="emit('toggle-selection', $event)"
@toggle-force="store.toggleForce"
@ignore-item="store.ignoreItem" @ignore-item="store.ignoreItem"
@unignore-item="store.unignoreItem" @unignore-item="store.unignoreItem"
/> />
@@ -110,6 +122,15 @@ const props = defineProps<{
const selectedCount = computed(() => props.store.getSelectedItems().length); const selectedCount = computed(() => props.store.getSelectedItems().length);
// 未开启强制安装的被锁定(held)软件数量,用于顶部提示
const heldLockedCount = computed(
() =>
props.store.snapshot.value.items.filter(
(i) =>
i.held === true && !props.store.forcedTaskKeys.value.has(i.taskKey),
).length,
);
const onOverlayWheel = (e: WheelEvent) => { const onOverlayWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest(".overflow-y-auto, .overflow-auto")) return; if (target.closest(".overflow-y-auto, .overflow-auto")) return;
@@ -4,20 +4,22 @@
:class=" :class="
item.ignored === true item.ignored === true
? 'border-slate-200/50 bg-slate-50/60 opacity-70 dark:border-slate-800/50 dark:bg-slate-900/40' ? 'border-slate-200/50 bg-slate-50/60 opacity-70 dark:border-slate-800/50 dark:bg-slate-900/40'
: 'border-slate-200/70 bg-white/90 dark:border-slate-800/70 dark:bg-slate-900/70' : item.held === true
? 'border-amber-300/70 bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10'
: 'border-slate-200/70 bg-white/90 dark:border-slate-800/70 dark:bg-slate-900/70'
" "
> >
<input <input
type="checkbox" type="checkbox"
class="h-4 w-4 shrink-0 rounded border-slate-300 accent-brand focus:ring-brand" class="h-4 w-4 shrink-0 rounded border-slate-300 accent-brand focus:ring-brand"
:checked="selected" :checked="selected"
:disabled="item.ignored === true" :disabled="isSelectionLocked"
@change="$emit('toggle-selection')" @change="$emit('toggle-selection')"
/> />
<div <div
class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500" class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500"
:class="item.ignored === true ? 'opacity-60' : ''" :class="item.ignored === true || item.held === true ? 'opacity-60' : ''"
> >
<img <img
:src="iconSrc" :src="iconSrc"
@@ -50,6 +52,18 @@
> >
已忽略 已忽略
</span> </span>
<span
v-else-if="item.held === true && !forced"
class="shrink-0 rounded-full bg-amber-200 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-500/20 dark:text-amber-200"
>
已锁定
</span>
<span
v-else-if="item.held === true && forced"
class="shrink-0 rounded-full bg-amber-500 px-2 py-0.5 text-[10px] font-semibold text-white dark:bg-amber-500"
>
将强制
</span>
</div> </div>
<p class="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400"> <p class="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
{{ item.packageName }} · {{ item.currentVersion }} {{ item.packageName }} · {{ item.currentVersion }}
@@ -66,7 +80,7 @@
</div> </div>
<div <div
v-if="item.ignored !== true" v-if="item.ignored !== true && !task"
class="flex shrink-0 flex-col items-end gap-0.5" class="flex shrink-0 flex-col items-end gap-0.5"
> >
<span class="text-[11px] font-semibold text-brand dark:text-amber-300">{{ <span class="text-[11px] font-semibold text-brand dark:text-amber-300">{{
@@ -74,13 +88,39 @@
}}</span> }}</span>
<span class="text-[10px] text-slate-400">{{ sizeLabel }}</span> <span class="text-[10px] text-slate-400">{{ sizeLabel }}</span>
</div> </div>
<div v-else class="flex shrink-0 flex-col items-end gap-0.5"> <div
v-else-if="!task"
class="flex shrink-0 flex-col items-end gap-0.5"
>
<span class="text-[11px] font-semibold text-slate-400">{{ <span class="text-[11px] font-semibold text-slate-400">{{
timeLabel timeLabel
}}</span> }}</span>
<span class="text-[10px] text-slate-400">{{ sizeLabel }}</span> <span class="text-[10px] text-slate-400">{{ sizeLabel }}</span>
</div> </div>
<!-- 强制安装开关仅对被系统锁定held的包显示 -->
<div
v-if="item.held === true && !task"
class="flex shrink-0 items-center gap-1"
:title="forced ? '已开启强制安装' : '强制安装被锁定的软件'"
>
<button
type="button"
class="relative inline-flex h-[18px] w-[34px] items-center rounded-full transition"
:class="forced ? 'bg-brand' : 'bg-slate-300 dark:bg-slate-600'"
role="switch"
:aria-checked="forced"
aria-label="强制安装"
@click.stop="$emit('toggle-force')"
>
<span
class="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition"
:class="forced ? 'translate-x-[16px]' : 'translate-x-[2px]'"
></span>
</button>
<span class="text-[10px] text-slate-400">强制</span>
</div>
<button <button
v-if="item.ignored === true" v-if="item.ignored === true"
type="button" type="button"
@@ -124,6 +164,8 @@ const props = defineProps<{
item: UpdateCenterItem; item: UpdateCenterItem;
task?: UpdateCenterTaskState; task?: UpdateCenterTaskState;
selected: boolean; selected: boolean;
// 是否已开启强制安装(仅对 held 项有意义)
forced?: boolean;
}>(); }>();
const PLACEHOLDER_ICON = const PLACEHOLDER_ICON =
@@ -134,8 +176,14 @@ defineEmits<{
(e: "toggle-selection"): void; (e: "toggle-selection"): void;
(e: "ignore-item"): void; (e: "ignore-item"): void;
(e: "unignore-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 => { const normalizeIconSrc = (icon: string): string => {
if (/^[a-z]+:\/\//i.test(icon)) { if (/^[a-z]+:\/\//i.test(icon)) {
return icon; return icon;
@@ -15,7 +15,9 @@
:item="item" :item="item"
:task="taskMap.get(item.taskKey)" :task="taskMap.get(item.taskKey)"
:selected="selectedTaskKeys.has(item.taskKey)" :selected="selectedTaskKeys.has(item.taskKey)"
:forced="forcedTaskKeys.has(item.taskKey)"
@toggle-selection="$emit('toggle-selection', item.taskKey)" @toggle-selection="$emit('toggle-selection', item.taskKey)"
@toggle-force="$emit('toggle-force', item.taskKey)"
@ignore-item="$emit('ignore-item', item.packageName, item.newVersion)" @ignore-item="$emit('ignore-item', item.packageName, item.newVersion)"
@unignore-item=" @unignore-item="
$emit('unignore-item', item.packageName, item.newVersion) $emit('unignore-item', item.packageName, item.newVersion)
@@ -40,11 +42,13 @@ const props = defineProps<{
items: UpdateCenterItemModel[]; items: UpdateCenterItemModel[];
tasks: UpdateCenterTaskState[]; tasks: UpdateCenterTaskState[];
selectedTaskKeys: Set<string>; selectedTaskKeys: Set<string>;
forcedTaskKeys: Set<string>;
apps: App[]; apps: App[];
}>(); }>();
defineEmits<{ defineEmits<{
(e: "toggle-selection", taskKey: string): void; (e: "toggle-selection", taskKey: string): void;
(e: "toggle-force", taskKey: string): void;
(e: "ignore-item", packageName: string, newVersion: string): void; (e: "ignore-item", packageName: string, newVersion: string): void;
(e: "unignore-item", packageName: string, newVersion: string): void; (e: "unignore-item", packageName: string, newVersion: string): void;
}>(); }>();
@@ -11,7 +11,7 @@
集中管理 APM 与传统 deb 更新任务 集中管理 APM 与传统 deb 更新任务
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center justify-end gap-2">
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800" class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@@ -21,6 +21,37 @@
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loading }"></i> <i class="fas fa-sync-alt" :class="{ 'animate-spin': loading }"></i>
{{ loading ? "刷新中" : "刷新" }} {{ loading ? "刷新中" : "刷新" }}
</button> </button>
<label
class="inline-flex cursor-pointer items-center gap-2 select-none rounded-2xl border border-slate-200/70 px-3 py-2 transition hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800"
:class="{
'cursor-not-allowed opacity-50 hover:bg-transparent dark:hover:bg-transparent':
selectableCount === 0,
}"
:title="
selectableCount === 0
? '当前没有可更新项(全部被锁定未强制或已忽略)'
: '全选所有可更新项'
"
>
<input
ref="selectAllRef"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand disabled:cursor-not-allowed"
:checked="allSelected"
:disabled="selectableCount === 0"
@change="$emit('toggle-select-all')"
/>
<span class="text-sm font-medium text-slate-700 dark:text-slate-200"
>全选</span
>
</label>
<span
class="text-sm text-slate-400 dark:text-slate-500"
:title="selectableCount === 0 ? '无可更新项' : '已选中的更新项数量'"
>
<template v-if="selectableCount === 0">无可更新项</template>
<template v-else>已选 {{ selectedCount }} </template>
</span>
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40" class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
@@ -41,24 +72,6 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-3">
<label class="inline-flex cursor-pointer items-center gap-2 select-none">
<input
ref="selectAllRef"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
:checked="allSelected"
@change="$emit('toggle-select-all')"
/>
<span class="text-sm font-medium text-slate-700 dark:text-slate-200"
>全选</span
>
</label>
<span class="text-sm text-slate-400 dark:text-slate-500">
已选 {{ selectedCount }}
</span>
</div>
<label class="block relative"> <label class="block relative">
<span class="sr-only">搜索更新</span> <span class="sr-only">搜索更新</span>
<i <i
@@ -89,6 +102,7 @@ import { ref, watch } from "vue";
const props = defineProps<{ const props = defineProps<{
searchQuery: string; searchQuery: string;
selectedCount: number; selectedCount: number;
selectableCount: number;
allSelected: boolean; allSelected: boolean;
someSelected: boolean; someSelected: boolean;
loading?: boolean; loading?: boolean;
+4
View File
@@ -158,6 +158,8 @@ export interface UpdateCenterItem {
aptssVersion?: string; aptssVersion?: string;
// 更新发布时间(毫秒时间戳),用于列表显示「X天前」;暂无数据时前端降级为「—」 // 更新发布时间(毫秒时间戳),用于列表显示「X天前」;暂无数据时前端降级为「—」
updateTime?: number; updateTime?: number;
// 是否被系统锁定(apt-mark hold)。被锁定项默认不可批量选中,需用户单独开启强制安装
held?: boolean;
} }
export interface UpdateCenterTaskState { export interface UpdateCenterTaskState {
@@ -175,6 +177,8 @@ export interface UpdateCenterTaskState {
export interface UpdateCenterStartTask { export interface UpdateCenterStartTask {
taskKey: string; taskKey: string;
id: number; id: number;
// 强制安装被系统锁定(apt-mark hold)的包
forceHeld?: boolean;
} }
export interface UpdateCenterSnapshot { export interface UpdateCenterSnapshot {
+11 -2
View File
@@ -219,9 +219,15 @@ window.ipcRenderer.on("install-log", (_event, log: InstallLog) => {
window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => { window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => {
const downloadObj = downloads.value.find((d) => d.id === log.id); const downloadObj = downloads.value.find((d) => d.id === log.id);
if (downloadObj) { if (downloadObj) {
// 更新中心来源的任务用「更新」语义,其余(普通安装)用「安装」语义,
// 避免把安装结果误报为「下载完成」。
const isUpdate = (downloadObj.source ?? "").toLowerCase().includes("update");
const doneLabel = isUpdate ? "更新完成" : "安装完成";
const failVerb = isUpdate ? "更新失败" : "安装失败";
if (log.success) { if (log.success) {
downloadObj.status = "completed"; downloadObj.status = "completed";
downloadObj.logs.push({ time: Date.now(), message: "下载完成" }); downloadObj.logs.push({ time: Date.now(), message: doneLabel });
} else { } else {
downloadObj.status = "failed"; downloadObj.status = "failed";
// 将失败原因写入日志,避免 UI 停在"正在获取 Metalink"等中间日志后突兀结束 // 将失败原因写入日志,避免 UI 停在"正在获取 Metalink"等中间日志后突兀结束
@@ -232,7 +238,10 @@ window.ipcRenderer.on("install-complete", (_event, log: DownloadResult) => {
} catch { } catch {
reason = log.message || reason; reason = log.message || reason;
} }
downloadObj.logs.push({ time: Date.now(), message: `下载失败: ${reason}` }); downloadObj.logs.push({
time: Date.now(),
message: `${failVerb}: ${reason}`,
});
} }
} }
}); });
+57 -9
View File
@@ -24,10 +24,12 @@ export interface UpdateCenterStore {
showMigrationConfirm: Ref<boolean>; showMigrationConfirm: Ref<boolean>;
searchQuery: Ref<string>; searchQuery: Ref<string>;
selectedTaskKeys: Ref<Set<string>>; selectedTaskKeys: Ref<Set<string>>;
forcedTaskKeys: Ref<Set<string>>;
snapshot: Ref<UpdateCenterSnapshot>; snapshot: Ref<UpdateCenterSnapshot>;
filteredItems: ComputedRef<UpdateCenterItem[]>; filteredItems: ComputedRef<UpdateCenterItem[]>;
allSelected: ComputedRef<boolean>; allSelected: ComputedRef<boolean>;
someSelected: ComputedRef<boolean>; someSelected: ComputedRef<boolean>;
selectableCount: ComputedRef<number>;
bind: () => void; bind: () => void;
unbind: () => void; unbind: () => void;
open: (storeFilter?: StoreFilter) => Promise<void>; open: (storeFilter?: StoreFilter) => Promise<void>;
@@ -35,6 +37,7 @@ export interface UpdateCenterStore {
ignoreItem: (packageName: string, newVersion: string) => Promise<void>; ignoreItem: (packageName: string, newVersion: string) => Promise<void>;
unignoreItem: (packageName: string, newVersion: string) => Promise<void>; unignoreItem: (packageName: string, newVersion: string) => Promise<void>;
toggleSelection: (taskKey: string) => void; toggleSelection: (taskKey: string) => void;
toggleForce: (taskKey: string) => void;
toggleSelectAll: () => void; toggleSelectAll: () => void;
getSelectedItems: () => UpdateCenterItem[]; getSelectedItems: () => UpdateCenterItem[];
closeNow: () => void; closeNow: () => void;
@@ -60,21 +63,32 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const showMigrationConfirm = ref(false); const showMigrationConfirm = ref(false);
const searchQuery = ref(""); const searchQuery = ref("");
const selectedTaskKeys = ref(new Set<string>()); const selectedTaskKeys = ref(new Set<string>());
// 强制安装集合:仅当前会话内有效(刷新/重开更新中心即重置),不持久化。
// 仅对被系统锁定(held)的项有意义:开启后该 held 项可被单独选中并升级。
const forcedTaskKeys = ref(new Set<string>());
const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT); const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT);
let lastStoreFilter: StoreFilter = "both"; let lastStoreFilter: StoreFilter = "both";
// 判断某 item 是否可被选中:未被忽略,且(未被锁定 或 已开启强制安装)
const isSelectable = (item: UpdateCenterItem): boolean => {
if (item.ignored === true) return false;
if (item.held === true && !forcedTaskKeys.value.has(item.taskKey)) {
return false;
}
return true;
};
const resetSessionState = (): void => { const resetSessionState = (): void => {
showCloseConfirm.value = false; showCloseConfirm.value = false;
showMigrationConfirm.value = false; showMigrationConfirm.value = false;
searchQuery.value = ""; searchQuery.value = "";
selectedTaskKeys.value = new Set(); selectedTaskKeys.value = new Set();
forcedTaskKeys.value = new Set();
}; };
const applySnapshot = (nextSnapshot: UpdateCenterSnapshot): void => { const applySnapshot = (nextSnapshot: UpdateCenterSnapshot): void => {
const selectableTaskKeys = new Set( const selectableTaskKeys = new Set(
nextSnapshot.items nextSnapshot.items.filter(isSelectable).map((item) => item.taskKey),
.filter((item) => item.ignored !== true)
.map((item) => item.taskKey),
); );
selectedTaskKeys.value = new Set( selectedTaskKeys.value = new Set(
[...selectedTaskKeys.value].filter((taskKey) => [...selectedTaskKeys.value].filter((taskKey) =>
@@ -85,7 +99,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
}; };
const selectableItems = computed(() => const selectableItems = computed(() =>
snapshot.value.items.filter((item) => item.ignored !== true), snapshot.value.items.filter(isSelectable),
); );
const filteredItems = computed(() => { const filteredItems = computed(() => {
@@ -93,10 +107,11 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const matched = snapshot.value.items.filter((item) => const matched = snapshot.value.items.filter((item) =>
matchesSearch(item, query), matchesSearch(item, query),
); );
// 已忽略项沉底:非忽略在前、已忽略在后,各自保持原有顺序 // 已忽略项沉底:可选(含已强制的锁定项)在前、不可选(已忽略 / 未强制的锁定项)在后,
// 各自保持原有顺序
return [ return [
...matched.filter((item) => item.ignored !== true), ...matched.filter(isSelectable),
...matched.filter((item) => item.ignored === true), ...matched.filter((item) => !isSelectable(item)),
]; ];
}); });
@@ -116,6 +131,9 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
); );
}); });
// 可选项数量:被忽略 / 被锁定未强制的项不计入。用于全选按钮可用性判断。
const selectableCount = computed(() => selectableItems.value.length);
const handleState = (nextSnapshot: UpdateCenterSnapshot): void => { const handleState = (nextSnapshot: UpdateCenterSnapshot): void => {
applySnapshot(nextSnapshot); applySnapshot(nextSnapshot);
}; };
@@ -240,7 +258,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const item = snapshot.value.items.find( const item = snapshot.value.items.find(
(entry) => entry.taskKey === taskKey, (entry) => entry.taskKey === taskKey,
); );
if (!item || item.ignored === true) { if (!item || !isSelectable(item)) {
return; return;
} }
@@ -254,6 +272,32 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
selectedTaskKeys.value = nextSelection; selectedTaskKeys.value = nextSelection;
}; };
// 切换「强制安装」开关(仅对被系统锁定的包有意义)。
// 关闭强制时,若该包已被选中则同步取消其选中态。
const toggleForce = (taskKey: string): void => {
const item = snapshot.value.items.find(
(entry) => entry.taskKey === taskKey,
);
if (!item || item.held !== true) {
return;
}
const nextForced = new Set(forcedTaskKeys.value);
if (nextForced.has(taskKey)) {
nextForced.delete(taskKey);
// 取消强制后该包不可再被选中,移出选中集
if (selectedTaskKeys.value.has(taskKey)) {
const nextSelection = new Set(selectedTaskKeys.value);
nextSelection.delete(taskKey);
selectedTaskKeys.value = nextSelection;
}
} else {
nextForced.add(taskKey);
}
forcedTaskKeys.value = nextForced;
};
const toggleSelectAll = (): void => { const toggleSelectAll = (): void => {
const selectable = selectableItems.value; const selectable = selectableItems.value;
if (allSelected.value) { if (allSelected.value) {
@@ -266,7 +310,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
const getSelectedItems = (): UpdateCenterItem[] => { const getSelectedItems = (): UpdateCenterItem[] => {
return snapshot.value.items.filter( return snapshot.value.items.filter(
(item) => (item) =>
selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true, selectedTaskKeys.value.has(item.taskKey) && isSelectable(item),
); );
}; };
@@ -328,6 +372,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
startTasks.push({ startTasks.push({
taskKey: item.taskKey, taskKey: item.taskKey,
id: downloadId, id: downloadId,
forceHeld: item.held === true,
}); });
} }
}); });
@@ -351,10 +396,12 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
showMigrationConfirm, showMigrationConfirm,
searchQuery, searchQuery,
selectedTaskKeys, selectedTaskKeys,
forcedTaskKeys,
snapshot, snapshot,
filteredItems, filteredItems,
allSelected, allSelected,
someSelected, someSelected,
selectableCount,
bind, bind,
unbind, unbind,
open, open,
@@ -362,6 +409,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => {
ignoreItem, ignoreItem,
unignoreItem, unignoreItem,
toggleSelection, toggleSelection,
toggleForce,
toggleSelectAll, toggleSelectAll,
getSelectedItems, getSelectedItems,
closeNow, closeNow,
+198
View File
@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>更新中心 · hold/强制安装 设计草图</title>
<style>
:root{
--brand:#0071e3; --brand-dark:#0066cc; --brand-soft:#409cff;
--amber:#f59e0b; --amber-soft:#fbbf24;
--bg:#f5f7fb; --card:#fff; --line:#e2e8f0; --txt:#0f172a; --sub:#64748b;
--danger:#ef4444;
}
*{box-sizing:border-box;font-family:"Inter","system-ui","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--bg);color:var(--txt);padding:32px;}
h1{font-size:20px;margin:0 0 4px;}
.hint{color:var(--sub);font-size:13px;margin-bottom:24px;line-height:1.6;}
.modal{width:100%;max-width:960px;margin:0 auto;background:var(--card);border:1px solid var(--line);
border-radius:24px;overflow:hidden;box-shadow:0 20px 50px rgba(15,23,42,.12);}
.toolbar{display:flex;align-items:center;gap:12px;padding:18px 24px;border-bottom:1px solid var(--line);}
.toolbar .title{font-weight:700;font-size:16px;}
.toolbar .spacer{flex:1;}
.btn{border:1px solid var(--line);background:#fff;border-radius:12px;padding:8px 14px;font-size:13px;cursor:pointer;}
.btn-primary{background:var(--brand);color:#fff;border-color:var(--brand);}
.btn:disabled{opacity:.5;cursor:not-allowed;}
.warn{margin:16px 24px;background:#fffbeb;border:1px solid #fde68a;color:#92400e;
border-radius:16px;padding:12px 16px;font-size:13px;line-height:1.6;}
.list{padding:8px 16px 20px;}
.item{position:relative;display:flex;align-items:center;gap:12px;border:1px solid var(--line);
border-radius:16px;padding:12px 16px;margin-top:10px;}
.item.held{background:#fffdf5;border-color:#fde68a;}
.item.sel{background:rgba(0,113,227,.04);}
.chk{width:18px;height:18px;accent-color:var(--brand);}
.icon{width:40px;height:40px;border-radius:10px;background:#eef2f7;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:18px;}
.main{flex:1;min-width:0;}
.name-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
.name{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tag{font-size:10px;font-weight:600;border-radius:999px;padding:2px 8px;}
.tag-src{background:#eef2f7;color:#475569;}
.tag-held{background:#fef3c7;color:#b45309;}
.tag-ign{background:#e2e8f0;color:#64748b;}
.ver{font-size:12px;color:var(--sub);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.right{text-align:right;font-size:12px;color:#475569;white-space:nowrap;}
.right .t{color:var(--brand);font-weight:600;font-size:11px;}
.right .s{color:#94a3b8;font-size:10px;}
.actions{display:flex;align-items:center;gap:6px;}
.iconbtn{width:32px;height:32px;border-radius:10px;border:1px solid var(--line);background:#fff;
display:flex;align-items:center;justify-content:center;cursor:pointer;color:#475569;font-size:13px;}
.iconbtn.amber{color:var(--amber);}
.iconbtn.amber:hover{background:#fffbeb;}
/* 强制安装:开关样式 */
.force{display:flex;align-items:center;gap:6px;}
.switch{position:relative;width:34px;height:18px;border-radius:999px;background:#cbd5e1;transition:.2s;flex:none;}
.switch.on{background:var(--brand);}
.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:.2s;}
.switch.on::after{left:18px;}
.force-label{font-size:11px;color:var(--sub);}
/* 底部说明条 */
.legend{margin:0 24px 20px;font-size:12px;color:var(--sub);line-height:1.8;
border-top:1px dashed var(--line);padding-top:14px;}
.legend b{color:var(--txt);}
/* 布局说明块 */
.notes{max-width:960px;margin:28px auto 0;background:#fff;border:1px solid var(--line);
border-radius:16px;padding:18px 22px;font-size:13px;line-height:1.8;color:#334155;}
.notes h2{font-size:15px;margin:0 0 10px;}
.notes code{background:#f1f5f9;padding:1px 6px;border-radius:6px;font-size:12px;color:#0f172a;}
.notes li{margin-bottom:6px;}
</style>
</head>
<body>
<h1>更新中心 — hold 标签 & 强制安装(设计草图)</h1>
<p class="hint">
说明:与现有「忽略」机制一致 —— 被系统 <code>apt-mark hold</code> 的包在更新中心默认<b>不可选中</b>(复选框禁用),
并在软件上显示「已锁定 / hold」标签;同时该软件行提供<b>「强制安装」开关</b>,打开后该包可单独被选中并升级
(后端对 spark 源追加 <code>--allow-change-held-packages</code>)。其他功能(忽略、批量升级、进度)不变。
</p>
<div class="modal">
<div class="toolbar">
<span class="title">更新中心</span>
<span class="spacer"></span>
<button class="btn">全选</button>
<button class="btn">刷新</button>
<button class="btn" disabled>开始更新 (2)</button>
<button class="btn">关闭</button>
</div>
<div class="warn">
⚠ 检测到 1 个软件被系统锁定(hold),默认不会升级。如需强制升级,请在对应软件上打开「强制安装」。
</div>
<div class="list">
<!-- 普通可升级项 -->
<div class="item sel">
<input type="checkbox" class="chk" checked />
<div class="icon">📦</div>
<div class="main">
<div class="name-row">
<span class="name">deepin-terminal</span>
<span class="tag tag-src">传统deb</span>
</div>
<div class="ver">6.0.1 → 6.0.2</div>
</div>
<div class="right"><div class="t">今天</div><div class="s">48 MB</div></div>
<div class="actions">
<div class="iconbtn amber" title="忽略更新">👁̶</div>
</div>
</div>
<!-- 被 hold 的软件:默认禁用复选框 + 显示 hold 标签 + 提供强制安装开关 -->
<div class="item held">
<input type="checkbox" class="chk" disabled title="已被系统锁定,需开启强制安装" />
<div class="icon">📦</div>
<div class="main">
<div class="name-row">
<span class="name">code</span>
<span class="tag tag-src">传统deb</span>
<span class="tag tag-held">已锁定</span>
</div>
<div class="ver">1.133.0 → 1.133.1</div>
</div>
<div class="right"><div class="t">3天前</div><div class="s">95 MB</div></div>
<div class="actions">
<!-- 强制安装开关 -->
<div class="force" title="强制安装被锁定的软件">
<div class="switch"></div>
<span class="force-label">强制</span>
</div>
<div class="iconbtn amber" title="忽略更新">👁̶</div>
</div>
</div>
<!-- 已开启强制安装的 hold 软件:复选框可用,标签变为「将强制」 -->
<div class="item held sel">
<input type="checkbox" class="chk" checked />
<div class="icon">📦</div>
<div class="main">
<div class="name-row">
<span class="name">code</span>
<span class="tag tag-src">传统deb</span>
<span class="tag tag-held">将强制</span>
</div>
<div class="ver">1.133.0 → 1.133.1</div>
</div>
<div class="right"><div class="t">3天前</div><div class="s">95 MB</div></div>
<div class="actions">
<div class="force" title="强制安装被锁定的软件">
<div class="switch on"></div>
<span class="force-label">强制</span>
</div>
<div class="iconbtn amber" title="忽略更新">👁̶</div>
</div>
</div>
<!-- 已忽略项(沉底,仅作对照) -->
<div class="item" style="opacity:.7;">
<input type="checkbox" class="chk" disabled />
<div class="icon">📦</div>
<div class="main">
<div class="name-row">
<span class="name">old-app</span>
<span class="tag tag-src">APM</span>
<span class="tag tag-ign">已忽略</span>
</div>
<div class="ver">2.1 → 2.2</div>
</div>
<div class="right"><div class="t">30天前</div><div class="s"></div></div>
<div class="actions">
<div class="iconbtn" title="取消忽略"></div>
</div>
</div>
</div>
<div class="legend">
<b>图例:</b>
<b>普通项</b>:可正常勾选升级;
<b>已锁定(hold)项</b>:复选框禁用、显示「已锁定」黄色标签,与「已忽略」一样沉底、不可随「全选」选中;
③ 每行末的 <b>「强制」开关</b> 仅对被锁定的软件显示,打开后复选框启用、标签变「将强制」,可被单独勾选升级;
<b>忽略</b>按钮(👁̶)对所有可操作项保留,逻辑不变。
</div>
</div>
<div class="notes">
<h2>实现逻辑(动手前请确认此方案)</h2>
<ul>
<li><b>数据来源</b>:后端 <code>service.ts</code> 构建 <code>UpdateCenterItem</code> 时,调用 <code>apt-mark showhold</code>(或 <code>dpkg</code>)检测该包是否被 hold,新增字段 <code>held?: boolean</code>;前端类型同步加该字段。</li>
<li><b>默认不可选</b>:与 <code>ignored</code> 一致——被 hold 的项的复选框 <code>:disabled</code><code>toggleSelection</code>/<code>selectableItems</code>/<code>filteredItems</code>/<code>start()</code> 中把它当作不可选(<code>held === true</code><code>ignored === true</code> 处理)。</li>
<li><b>强制开关</b>:前端 store 维护一个 <code>forcedTaskKeys: Set&lt;string&gt;</code>;开关打开的项,即使 <code>held</code> 也允许被 <code>selected</code><code>start()</code> 时把 <code>forced</code> 标记带入 <code>QueueInstallPayload</code></li>
<li><b>后端执行</b><code>install-manager.ts</code><code>addInstallTask</code> 收到 <code>forceHeld: true</code><code>origin==="spark"</code> 时,给 <code>ssinstall</code> 追加 <code>--allow-change-held-packages</code>(仅 spark 源;apm 源不涉及 apt hold)。</li>
<li><b>状态文案修复(顺带)</b>:前次日志「下载完成」误把安装失败当成功;本次如需一并修,可在 <code>processInstall.ts</code> 区分下载/安装完成文案,并让 <code>install-complete</code> 的 success 真实反映 apt 退出码(ssinstall 放弃安装却返回 0 的问题需另行处理)。<em>此项是否做,等你指示。</em></li>
<li><b>「强制」状态不持久化</b>:与选中态一样,仅本次弹窗会话内有效,刷新/重开恢复默认(避免用户忘记关掉强制)。</li>
</ul>
</div>
</body>
</html>