14 KiB
Windows x64 客户端更新 can_install=False 诊断
结论
“已识别最新版本,但按钮显示「暂不可安装」并提示后台尚未配置安装包”并不等价于只有一种后台配置错误。当前链路把多种拒绝原因压缩为同一个 UpdateOffer.can_install=False,而对话框的兜底文案统一归因为“后台未配置”。
对标准 Windows x64 客户端,最值得按以下顺序检查:
- 服务端没有在
packages.windows_x64取到同时非空的url和sha256。 最新版本是全局字段,安装包是按平台另行选择,因此完全可能has_update=True但can_install=False。 - Windows Inno Setup 包使用了 HTTP(非 localhost)地址,或本机关闭了 HTTPS 证书校验。 前者会被解析器拒绝;后者会在 UI session 中把一个原本可安装的 offer 二次降级为不可安装。
- 服务端返回了非 64 位十六进制 SHA-256。 当前 PHP evaluate 只检查 SHA 是否非空,Python 客户端则做严格格式校验,两端判定可能不一致。
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 的请求应为:
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_setupURL 必须是 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)。
这是最容易被误判为“后台没包”的非后台原因。诊断时应比较两个时点:
fetch_update_offer()刚返回时是否can_install=True;_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)。
最短现场排查路径
-
用发生问题的当前版本请求实际 API,并保留解包后的
data:/adminapi/setting.desktop_workstation/check?current_version=<version>&platform=windows&arch=x64 -
若响应已经是
can_install:false且package:null,读取管理端配置并核对packages.windows_x64.url与.sha256是否同时非空;确认包没有误填到macos_x64,也没有只保存最新版本而未保存包。 -
若响应是
can_install:true,核对package是否为对象、SHA 是否 64 位十六进制、type是否精确为archive或inno_setup。若为 Inno Setup,URL 应为 HTTPS。 -
若解析后 offer 为 true、弹窗前变成 false,检查客户端
preferences.json中的verify_ssl,以及登录页“信任自签名证书”是否被勾选。 -
对 Windows 安装程序,期望响应至少应类似:
{ "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)。
建议新增以下测试:
- Windows x64 服务端缺字段矩阵(最高优先级):分别让
packages.windows_x64.url为空、sha256为空、整个键缺失;断言has_update=true、package=null、can_install=false。这会直接固化本次症状。 - 服务端/客户端 SHA 契约一致性:给 evaluate 一个“非空但不是 64 位十六进制”的 SHA。期望服务端也返回不可安装,或至少用共享 fixture 明确当前由客户端拒绝;避免两端一个 true、一个 false。
AppUpdateSessionTLS 二次降级:构造合法 HTTPSinno_setupoffer,分别设置verify_ssl=True/False,截获_present();true 时保持可安装,false 时断言can_install=False且原因明确指向证书策略而非后台缺包。- UI 兜底分支:构造
can_install=False且无 reason 的 offer,断言按钮禁用并展示后台/平台包缺失文案;与已有“注入 policy reason”的测试形成两条独立路径。 - 解析字段矩阵:补充
package=null、非对象、空 URL、63 位 SHA、含非 hex SHA、缺少 type 默认 archive、Windows archive 使用 HTTP 仍可解析等边界测试。现有测试覆盖了部分,但没有把每个判定条件与原因一一锁定。 - 跨层契约 fixture:把 PHP
check的 Windows x64 JSON 响应作为 Pythonparse_update_offer()输入,验证 canonicalwindows/x64、包类型、SHA 和can_install不发生语义漂移。 - 管理端 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.py21 项、test_app_update_ui.py3 项,共 24 项。 php tests/DesktopWorkstationUpdateContractTest.php(工作目录server/):Desktop workstation update contract: OK。