mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-09-20 21:50:11 +08:00
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:
Vendored
+1
-1
@@ -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、在贡献排行中,点击对应的贡献用户,可以查看用户下所有的应用(可能需要模糊查询)
|
||||||
@@ -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 明确显示「安装失败」而非「下载完成」
|
||||||
@@ -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) {
|
||||||
|
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(
|
execParams.push(
|
||||||
"ssinstall",
|
"ssinstall",
|
||||||
`${downloadDir}/${filename}`,
|
localDeb,
|
||||||
"--delete-after-install",
|
"--delete-after-install",
|
||||||
"--no-create-desktop-entry",
|
"--no-create-desktop-entry",
|
||||||
"--native",
|
"--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;
|
// 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 = {
|
const msgObj = {
|
||||||
message: success ? "安装完成" : `安装失败,退出码 ${result.code}`,
|
message: success
|
||||||
|
? "安装完成"
|
||||||
|
: `安装失败:${failureDetail || `退出码 ${result.code}`}`,
|
||||||
stdout: result.stdout,
|
stdout: result.stdout,
|
||||||
stderr: result.stderr,
|
stderr: result.stderr,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 标记。
|
||||||
|
// 仅 spark(aptss/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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
<div
|
||||||
class="flex items-center gap-4 rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
|
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="h-16 w-16 overflow-hidden rounded-2xl bg-slate-100 dark:bg-slate-800"
|
class="flex items-center gap-3 rounded-2xl border border-slate-200/60 p-3 dark:border-slate-800/60"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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' ||
|
||||||
|
|||||||
@@ -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,6 +4,8 @@
|
|||||||
: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'
|
||||||
|
: 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'
|
: 'border-slate-200/70 bg-white/90 dark:border-slate-800/70 dark:bg-slate-900/70'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -11,13 +13,13 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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<string></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>
|
||||||
Reference in New Issue
Block a user