159 lines
14 KiB
Markdown
159 lines
14 KiB
Markdown
# Windows x64 客户端更新 `can_install=False` 诊断
|
||
|
||
## 结论
|
||
|
||
“已识别最新版本,但按钮显示「暂不可安装」并提示后台尚未配置安装包”并不等价于只有一种后台配置错误。当前链路把多种拒绝原因压缩为同一个 `UpdateOffer.can_install=False`,而对话框的兜底文案统一归因为“后台未配置”。
|
||
|
||
对标准 Windows x64 客户端,最值得按以下顺序检查:
|
||
|
||
1. **服务端没有在 `packages.windows_x64` 取到同时非空的 `url` 和 `sha256`。** 最新版本是全局字段,安装包是按平台另行选择,因此完全可能 `has_update=True` 但 `can_install=False`。
|
||
2. **Windows Inno Setup 包使用了 HTTP(非 localhost)地址,或本机关闭了 HTTPS 证书校验。** 前者会被解析器拒绝;后者会在 UI session 中把一个原本可安装的 offer 二次降级为不可安装。
|
||
3. **服务端返回了非 64 位十六进制 SHA-256。** 当前 PHP evaluate 只检查 SHA 是否非空,Python 客户端则做严格格式校验,两端判定可能不一致。
|
||
4. **`package` 结构或 `package.type` 不符合客户端契约。** 客户端只接受对象形式的 `package`,类型只接受 `archive` / `inno_setup`;不过当前第一方 PHP 后端会把未知类型归一为 `archive`,当前管理端也只提供这两个选项,所以这通常只发生在旧服务、手工响应或绕过当前保存链路的配置中。
|
||
|
||
如果更新对话框确实已经出现,则单纯的版本、`enabled`、响应平台/架构不匹配通常可以排除:`AppUpdateSession._on_offer()` 在 `offer.has_update=False` 时直接返回,不会展示更新对话框(`app/src/doctor_workstation/ui/dialogs/app_update.py:360-368`)。
|
||
|
||
## 端到端链路与证据
|
||
|
||
### 1. 客户端发送的身份
|
||
|
||
- Windows 被映射为 `windows`(`app/src/doctor_workstation/services/app_update.py:78-83`)。
|
||
- `AMD64`、`x86_64`、`x64` 都被映射为 `x64`(`app/src/doctor_workstation/services/app_update.py:86-92`)。
|
||
- 检查请求固定发往 `setting.desktop_workstation/check`,携带 `current_version`、`platform`、`arch`(`app/src/doctor_workstation/services/app_update.py:218-243`)。
|
||
- `ApiClient` 会解开 `{code, data}` 信封,`code == 1` 时把 `data` 直接交给更新解析器(`app/src/doctor_workstation/services/api_client.py:515-542`)。
|
||
|
||
因此标准 64 位 Windows 的请求应为:
|
||
|
||
```text
|
||
GET /adminapi/setting.desktop_workstation/check
|
||
?current_version=<当前版本>&platform=windows&arch=x64
|
||
```
|
||
|
||
### 2. 服务端先决定是否有对应平台安装包
|
||
|
||
- 服务端只声明三个包槽位:`windows_x64`、`macos_arm64`、`macos_x64`(`server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:28-32`)。
|
||
- `windows`/`win32`/`win64` 会归一为 `windows`,`x64`/`amd64`/`x86_64` 会归一为 `x64`,然后拼成 `windows_x64`(同文件 `:125-153`)。
|
||
- evaluate 从 `config.packages[windows_x64]` 取包;服务端 `canInstall` 只要求 `url !== '' && sha256 !== ''`(同文件 `:75-86`)。
|
||
- `hasUpdate` 独立由启用状态和版本比较决定(`:86-88`),响应中只有 `canInstall` 为真才返回 `package`,最终 `can_install = hasUpdate && canInstall`(`:90-103`)。
|
||
|
||
这直接解释了核心现象:`latest_version` 配置正确会让客户端看到新版本,但 `packages.windows_x64.url` 或 `packages.windows_x64.sha256` 任一为空,响应仍会是 `has_update: true`、`package: null`、`can_install: false`。
|
||
|
||
管理端保存的真实字段是嵌套结构 `packages.windows_x64.{url,sha256,size,filename,type}`(`admin/src/api/setting/desktop_workstation.ts:5-24`、`admin/src/views/setting/desktop_workstation/index.vue:330-344`),而不是把 Windows 包放在 macOS 槽位或任意自定义键下。管理页默认 Windows 类型为 `inno_setup`(Vue 文件 `:198-218`),上传 `.exe` 也会设置为 `inno_setup` 并在浏览器计算 SHA-256(`:292-315`)。
|
||
|
||
当前服务端校验允许整行安装包为空:空值/空行会继续通过(`server/app/adminapi/validate/setting/DesktopWorkstationValidate.php:83-110`),所以“自动检测已启用、最新版本有效、Windows 包未完整配置”是被允许保存的状态。外部 URL 缺 SHA 会被拒绝,但站内相对 URL 对应文件不存在且 SHA 为空的情形仍可能保存;服务端只会在本地文件确实存在时自动补 SHA、大小和文件名(`DesktopWorkstationLogic.php:309-331`)。
|
||
|
||
### 3. Python 客户端会再做一轮更严格的判定
|
||
|
||
`parse_update_offer()` 的规则位于 `app/src/doctor_workstation/services/app_update.py:140-215`:
|
||
|
||
- `package` 必须是字典;`url` 必须非空(`:152-167`)。
|
||
- `type` 缺省为 `archive`,只接受 `archive` / `inno_setup`;`inno_setup` 只允许 Windows(`:158-167`)。
|
||
- 响应平台、架构必须与请求时的期望值完全一致;同时必须满足服务端 `has_update`、`enabled`、合法且更高的版本(`:175-184`)。
|
||
- SHA-256 必须恰好 64 个十六进制字符(`:185-189`)。
|
||
- `inno_setup` URL 必须是 HTTPS,唯一例外是 HTTP localhost/loopback(`:190-194`,具体 URL 规则在 `:371-376`)。
|
||
- 最终 `can_install` 是服务端 `can_install`、有效 package、有效 SHA、安全安装器传输、`has_update` 五者的合取(`:195-201`)。判失败后返回对象会清除 `package`,并把 `force` 一并降为 false(`:202-215`)。
|
||
|
||
因此若原始 API 返回 `can_install: true`,客户端仍可能因以下字段得到 false:
|
||
|
||
| 字段/状态 | 拒绝条件 | Windows x64 症状是否吻合 |
|
||
|---|---|---|
|
||
| `package` | `null`、数组、字符串等非对象 | 是 |
|
||
| `package.url` | 空字符串 | 是 |
|
||
| `package.sha256` | 空、长度不是 64、包含非十六进制字符 | 是 |
|
||
| `package.type` | 非 `archive` / `inno_setup` | 是,但当前第一方后端通常会归一为 `archive` |
|
||
| `package.type=inno_setup` + URL | 非 localhost 的 `http://` 或相对 URL | 是 |
|
||
| `package.filename` | 空或扩展名不匹配 | **不会在 offer 阶段令 `can_install=False`**;可能在下载/应用阶段失败 |
|
||
| `package.size` | 空、0、不可转整数 | **不会在 offer 阶段令 `can_install=False`**;解析为 0 |
|
||
| 缺少 `package.type` | 默认 `archive` | **不会单独导致 false**;EXE 被误当 archive 会在稍后解压失败 |
|
||
|
||
一个重要的不一致是:PHP evaluate 目前只检查 SHA 非空(`DesktopWorkstationLogic.php:85`),Python 检查完整格式(`app_update.py:185-189`)。管理端正常保存会校验 64 位十六进制(`DesktopWorkstationValidate.php:147-151`),但旧数据、直接写配置或绕过校验的导入仍可能造成“后端说可安装、客户端说不可安装”。
|
||
|
||
### 4. UI session 还会因本机 TLS 设置二次降级
|
||
|
||
即使 `fetch_update_offer()` 返回的 Inno Setup offer 已经 `can_install=True`,`AppUpdateSession._on_offer()` 仍会以本机 `config.verify_ssl` 调用安装器下载策略;失败时把 `force=False`、`package=None`、`can_install=False`(`app/src/doctor_workstation/ui/dialogs/app_update.py:371-392`)。
|
||
|
||
本机配置默认 `verify_ssl=True`(`app/src/doctor_workstation/config.py:88-97`、`:124-130`),但登录页勾选“信任自签名证书(仅内网调试)”会把它反转为 false(`app/src/doctor_workstation/ui/login.py:831-844`、`:937-944`、`:1041-1053`)。`validate_installer_download_policy()` 明确拒绝 `verify_ssl=False`,也拒绝非安全的 Inno Setup URL(`app/src/doctor_workstation/services/app_update.py:379-385`)。
|
||
|
||
这是最容易被误判为“后台没包”的非后台原因。诊断时应比较两个时点:
|
||
|
||
1. `fetch_update_offer()` 刚返回时是否 `can_install=True`;
|
||
2. `_on_offer()` 传给 `_present()` 时是否已经变成 false。
|
||
|
||
若只有第 2 个时点为 false,按当前代码唯一的正常降级入口就是 Inno Setup 下载策略,优先检查 `verify_ssl`。
|
||
|
||
审阅时工作树中已存在一项并非本文创建的未提交改善:`UpdateOffer` 增加 `install_unavailable_reason`,TLS 策略降级时生成具体原因,对话框优先展示该原因(`app_update.py` service `:53-67`;UI `:200-206`、`:379-391`)。兜底文案仍用于服务端/解析阶段没有原因的 `can_install=False`,所以根因判别和补测仍有必要。
|
||
|
||
### 5. 为什么平台或版本通常不是这个弹窗的根因
|
||
|
||
- 客户端要求响应 `platform`/`arch` 与请求期望值精确相等,错配会让 `has_update=False`(`app_update.py:175-184`)。
|
||
- session 对 `has_update=False` 直接显示“当前已是最新版本”或静默返回,不创建更新对话框(UI `:360-368`)。
|
||
- 当前第一方后端会把 Windows/x64 常见别名归一为响应中的 `windows`/`x64`(`DesktopWorkstationLogic.php:125-153`)。
|
||
|
||
所以对于已经出现该对话框的标准 Windows x64 客户端,优先查 `packages.windows_x64`,而不是先怀疑 `AMD64` 与 `x64` 名称差异。例外是非标准/旧后端没有按当前契约归一,或实际机器是 Windows ARM64;服务端没有 `windows_arm64` 包槽位,后者会天然没有对应包。
|
||
|
||
同理,`enabled=false`、最新版本无效、当前版本不低于最新版本都会使 `has_update=False`,与“更新弹窗出现但不可安装”不吻合。源码运行也不是该兜底文案的成因:源码模式只会取消强制属性,点击安装后才显示“当前为源码运行”(UI `:393-394`、`:401-410`)。
|
||
|
||
## 最短现场排查路径
|
||
|
||
1. 用发生问题的当前版本请求实际 API,并保留解包后的 `data`:
|
||
|
||
```text
|
||
/adminapi/setting.desktop_workstation/check?current_version=<version>&platform=windows&arch=x64
|
||
```
|
||
|
||
2. 若响应已经是 `can_install:false` 且 `package:null`,读取管理端配置并核对 `packages.windows_x64.url` 与 `.sha256` 是否同时非空;确认包没有误填到 `macos_x64`,也没有只保存最新版本而未保存包。
|
||
3. 若响应是 `can_install:true`,核对 `package` 是否为对象、SHA 是否 64 位十六进制、`type` 是否精确为 `archive` 或 `inno_setup`。若为 Inno Setup,URL 应为 HTTPS。
|
||
4. 若解析后 offer 为 true、弹窗前变成 false,检查客户端 `preferences.json` 中的 `verify_ssl`,以及登录页“信任自签名证书”是否被勾选。
|
||
5. 对 Windows 安装程序,期望响应至少应类似:
|
||
|
||
```json
|
||
{
|
||
"has_update": true,
|
||
"enabled": true,
|
||
"platform": "windows",
|
||
"arch": "x64",
|
||
"can_install": true,
|
||
"package": {
|
||
"url": "https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
|
||
"sha256": "<64 lowercase hex chars>",
|
||
"size": 123456789,
|
||
"filename": "DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
|
||
"type": "inno_setup"
|
||
}
|
||
}
|
||
```
|
||
|
||
## 现有测试覆盖与缺口
|
||
|
||
已有客户端测试覆盖:
|
||
|
||
- 缺 SHA 会拒绝安装(`app/tests/test_app_update.py:43-63`)。
|
||
- 合法 HTTPS Inno Setup 会接受(`:66-89`)。
|
||
- HTTP Inno Setup 会拒绝(`:92-115`)。
|
||
- 未知类型会拒绝(`:118-139`)。
|
||
- 旧版本/错误平台响应不会成为 update(`:142-163`)。
|
||
- 检查接口会发送 `platform=windows`、`arch=x64`(`:166-208`)。
|
||
- UI 的可选/强制升级基本行为,以及“给定 policy reason 时展示该 reason”(`app/tests/test_app_update_ui.py:49-96`)。
|
||
|
||
已有 PHP 契约测试覆盖 `win32 + amd64 -> windows_x64`,以及完整 Windows Inno 包可安装(`server/tests/DesktopWorkstationUpdateContractTest.php:17-62`);缺包测试只覆盖 macOS 槽位(`:74-77`)。
|
||
|
||
建议新增以下测试:
|
||
|
||
1. **Windows x64 服务端缺字段矩阵(最高优先级)**:分别让 `packages.windows_x64.url` 为空、`sha256` 为空、整个键缺失;断言 `has_update=true`、`package=null`、`can_install=false`。这会直接固化本次症状。
|
||
2. **服务端/客户端 SHA 契约一致性**:给 evaluate 一个“非空但不是 64 位十六进制”的 SHA。期望服务端也返回不可安装,或至少用共享 fixture 明确当前由客户端拒绝;避免两端一个 true、一个 false。
|
||
3. **`AppUpdateSession` TLS 二次降级**:构造合法 HTTPS `inno_setup` offer,分别设置 `verify_ssl=True/False`,截获 `_present()`;true 时保持可安装,false 时断言 `can_install=False` 且原因明确指向证书策略而非后台缺包。
|
||
4. **UI 兜底分支**:构造 `can_install=False` 且无 reason 的 offer,断言按钮禁用并展示后台/平台包缺失文案;与已有“注入 policy reason”的测试形成两条独立路径。
|
||
5. **解析字段矩阵**:补充 `package=null`、非对象、空 URL、63 位 SHA、含非 hex SHA、缺少 type 默认 archive、Windows archive 使用 HTTP 仍可解析等边界测试。现有测试覆盖了部分,但没有把每个判定条件与原因一一锁定。
|
||
6. **跨层契约 fixture**:把 PHP `check` 的 Windows x64 JSON 响应作为 Python `parse_update_offer()` 输入,验证 canonical `windows/x64`、包类型、SHA 和 `can_install` 不发生语义漂移。
|
||
7. **管理端 payload 测试**:确认保存时始终发送 `packages.windows_x64` 嵌套对象,上传 `.exe` 后 `type=inno_setup` 且 URL、SHA、文件名、大小均落在同一槽位。
|
||
|
||
长期看,最稳妥的可观测性是让 `can_install=False` 同时带结构化原因(例如 `missing_package`、`invalid_digest`、`unsupported_type`、`insecure_installer_url`、`tls_verification_disabled`),并在客户端保留原因而不是立即清除所有包信息。这样 UI 不必用一个“后台未配置”文案覆盖所有安全门禁。
|
||
|
||
## 验证记录
|
||
|
||
- 根目录 `.trellis/` 不存在;本次按根 `AGENTS.md` 执行,只读检查生产代码,仅新增本文档。
|
||
- `app/.venv/Scripts/python.exe -m pytest tests/test_app_update.py tests/test_app_update_ui.py -q`:通过。
|
||
- 收集结果:`test_app_update.py` 21 项、`test_app_update_ui.py` 3 项,共 24 项。
|
||
- `php tests/DesktopWorkstationUpdateContractTest.php`(工作目录 `server/`):`Desktop workstation update contract: OK`。
|
||
|