Files
navop/script/validate-windows-exe.mjs
胡飞 fa6ca0d1b1 fix(windows): 宿主自嵌 Common-Controls v6 清单,修 v0.20.1 启动即死
v0.20.1 的 Windows 产物(zip / portable / MSI 里装的是同一个 navop.exe)启动即失败:
「navop.exe - 无法找到入口 / 无法定位程序输入点 TaskDialogIndirect 于动态链接库
…\AppData\Local\Temp\navop-update\staged-<pid>-<ms>\navop.exe 上」。加载器在 `main`
之前就以 STATUS_ENTRYPOINT_NOT_FOUND(0xC0000139) 退出,所以更新表现成「什么都没发生」。

根因不是缺 comctl32.dll,而是产物**没有任何应用程序清单**:

- gpui 的 Windows 层在 `PlatformWindow::prompt` 中静态导入 `comctl32!TaskDialogIndirect`
  (实测 v0.20.1 的 navop.exe:IMPORT 表里有该符号、DELAY_IMPORT 目录为空 ⇒ load-time 导入);
- 该导出只存在于 Common-Controls v6 的 SxS 程序集,只有 exe 自己的清单声明了
  `Microsoft.Windows.Common-Controls 6.0.0.0` 才会激活 v6;没有清单就挂 System32 里的
  5.82(不导出该符号)⇒ 加载器失败;
- 实测 v0.20.1 的 navop.exe 全文件搜 `Common-Controls` / `<assembly` /
  `requestedExecutionLevel` 零命中 ⇒ 清单确实没被嵌进去。

清单的责任方在 v0.20.1 变了:`gpui-fast 0.1.8` 的 build.rs 是
`embed_resource_unless_host_provides_it()`,而 gpui-kit 声明了
`windows-manifest-provided-by-host`(`cargo tree -e features -p main -i gpui-fast` 可核)。
Cargo 特性只做并集、下游关不掉 ⇒ 这份清单只能由宿主 navop 自己提供。
v0.20.0 走旧门面 `gpui-pre`,它是无条件 embed 的,所以此前没暴露。

- `resources/windows/navop.manifest.xml`(新增):宿主清单,与上游 Zed 的
  `crates/gpui/resources/windows/gpui.manifest.xml` 对齐(asInvoker + supportedOS Win10 +
  Common-Controls 6.0.0.0 + `dpiAware true/pm` / `dpiAwareness PerMonitorV2` /
  `heapType SegmentHeap`)。DPI 与堆这一段刻意与 v0.20.0 保持一致:gpui 两代都**不**在运行时
  调 `SetProcessDpiAwareness`(全仓 grep 无命中),清单是 DPI 感知的唯一来源,漏掉会退回
  DPI-unaware(高 DPI 下整窗被系统位图拉伸)。文件不带 XML 声明:winresource 是把清单按 RC
  字符串逐行写成 `" <line> "`,声明前出现空白不是合法的 XML prolog。
- `main/build.rs`:加 `set_version_info(VersionInfo::FILETYPE, 1)` +
  `set_manifest(include_str!("../resources/windows/navop.manifest.xml"))`。前者不是可选项 ——
  winresource 只在 `VersionInfo::FILETYPE` 存在时才写清单块(`winresource-0.1.31/lib.rs:578`),
  漏了会**静默不嵌清单**。用 `set_manifest` 而非 `set_manifest_file`,避开 rc 的路径转义。
- `script/validate-windows-exe.mjs`(新增):PE 层门禁,解析节表定位 `.rsrc` 并在其原始区间内查
  三条必需串,另外判别「静态导入 TaskDialogIndirect 却无清单」这一真实故障组合;失败退出码 1
  并打印含 `0xC0000139` 的修法。判据**必须同时搜 ASCII 与 UTF-16LE**:实测 llvm-rc 对纯 ASCII
  清单写的是 8 位,MSVC rc.exe 才写 UTF-16,只搜一种编码必漏。
- `.github/workflows/release.yml`:Windows 腿在 `Build release binary (Windows)` 之后、
  `Package (Windows)` 之前插入 `Validate Windows executable manifest`。这段代码在 macOS/Linux 上
  根本不参与编译,而「清单没嵌进去」在编译期没有任何信号,只能在真实产物上校验。
- `script/test-release-packaging.mjs`:加合成 PE fixture(`peFixtureWithResource`)与两个用例
  —— 断言清单内容、`main/build.rs` 的三处调用、workflow 步骤的存在与顺序;以及门禁对
  正样本 / 负样本 / 非 PE 三种输入的退出码与报错内容。

验证(本机为 macOS,无法编译 Windows target):

- `node --test script/test-release-packaging.mjs` = 33 passed / 0 failed
- `cfg(target_os = "windows")` 块在 macOS 上不参与编译,`cargo check` 给不了信号。因此建探针
  工程把 `main/build.rs` **逐字复制、只删掉那行 `#[cfg]`**,配
  `[build-dependencies] winresource = "0.1"` ⇒ 该块**编译通过**(`set_version_info` /
  `set_manifest` 的签名与 `winresource 0.1.31` 一致)。
- 取 winresource 真实生成的 `resource.rc` 手工喂 `llvm-rc`:退出码 0,`.res` 资源目录读出
  `VERSION 1 / ICON 1..6 / GROUP_ICON 1 / RT_MANIFEST 1(1120 字节)`—— 清单落在加载器唯一认的
  「type 24 / id 1」位置。
- 把该 `.res` 当 `.rsrc` 负荷塞进合成 PE:门禁判 OK;对**真实 v0.20.1 的 navop.exe** 判 FAIL
  (退出码 1,报错含 `TaskDialogIndirect` 与 `0xC0000139`)。
- 未在本机验证:MSVC rc.exe / link.exe 真实产物能否正常启动、DPI 表现如何(需 Windows)。
  CI 的 `windows-2022` 腿会编译这段代码。
2026-10-13 19:54:41 +08:00

141 lines
5.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 校验 Windows 产物**内嵌**了 Common-Controls v6 清单。
//
// 为什么必须是这样:gpui 的 Windows 层静态导入 `comctl32!TaskDialogIndirect`
// (gpui_windows 的 `PlatformWindow::prompt`),而该导出只存在于 Common-Controls v6 的
// SxS 程序集。只有 exe 自己的清单声明了 `Microsoft.Windows.Common-Controls 6.0.0.0`,
// 加载器才会选 v6;否则它挂 System32 里的 5.82,进程在 `main` 之前就以
// STATUS_ENTRYPOINT_NOT_FOUND(0xC0000139) 退出,系统弹
// 「无法定位程序输入点 TaskDialogIndirect 于动态链接库 …\navop.exe 上」。
//
// 2026-10 的 v0.20.1 就是这么挂的:`gpui-fast` 的 build.rs 变成了
// `embed_resource_unless_host_provides_it()`,而 gpui-kit 声明了
// `windows-manifest-provided-by-host`(Cargo 特性只做并集、下游关不掉),宿主 navop 又
// 从来没提供清单 ⇒ 产出的 navop.exe 静态导入 comctl32 却没有清单,全部 Windows 产物不可用。
//
// 只对 navop.exe 用(WiX bundle `${PUBLIC_BASENAME}.exe` 是另一个程序,本来就没有这份清单)。
//
// 用法:node script/validate-windows-exe.mjs <navop.exe> [more exes...]
import fs from "node:fs";
import process from "node:process";
const REQUIRED_MARKERS = [
"Microsoft.Windows.Common-Controls",
"version=\"6.0.0.0\"",
"publicKeyToken=\"6595b64144ccf1df\"",
];
const utf16 = (text) => Buffer.from(text, "utf16le");
/** 返回 needle 在 haystack 中的所有出现位置(同时找 ASCII 与 UTF-16LE 两种编码)。 */
function findOffsets(haystack, needle) {
const offsets = [];
for (const encoded of [Buffer.from(needle, "utf8"), utf16(needle)]) {
let from = 0;
for (;;) {
const at = haystack.indexOf(encoded, from);
if (at < 0) {
break;
}
offsets.push(at);
from = at + 1;
}
}
return offsets;
}
/** 读取 PE 的节表;返回 { sections, resource },非 PE 时抛错。 */
function parsePeSections(buffer, file) {
if (buffer.length < 0x40 || buffer.toString("latin1", 0, 2) !== "MZ") {
throw new Error(`${file} 不是 PE 文件(缺少 MZ 头)`);
}
const peOffset = buffer.readUInt32LE(0x3c);
if (buffer.toString("latin1", peOffset, peOffset + 4) !== "PE\0\0") {
throw new Error(`${file} 不是 PE 文件(缺少 PE 签名)`);
}
const sectionCount = buffer.readUInt16LE(peOffset + 6);
const optionalHeaderSize = buffer.readUInt16LE(peOffset + 20);
const sectionTable = peOffset + 24 + optionalHeaderSize;
const sections = [];
for (let index = 0; index < sectionCount; index += 1) {
const at = sectionTable + index * 40;
if (at + 40 > buffer.length) {
throw new Error(`${file} 的节表被截断(第 ${index} 节)`);
}
sections.push({
name: buffer.toString("latin1", at, at + 8).replace(/\0+$/, ""),
rawOffset: buffer.readUInt32LE(at + 20),
rawSize: buffer.readUInt32LE(at + 16),
});
}
return { sections, resource: sections.find((section) => section.name === ".rsrc") };
}
function validate(file) {
const buffer = fs.readFileSync(file);
const { sections, resource } = parsePeSections(buffer, file);
const sizeMb = (buffer.length / 1024 / 1024).toFixed(1);
if (!resource || resource.rawSize === 0) {
return {
ok: false,
reason: `没有任何资源段(.rsrc),不可能携带内嵌清单。节:${sections.map((s) => s.name || "?").join(", ")}`,
};
}
const end = Math.min(resource.rawOffset + resource.rawSize, buffer.length);
const resources = buffer.subarray(resource.rawOffset, end);
const missing = REQUIRED_MARKERS.filter(
(marker) => findOffsets(resources, marker).length === 0,
);
const importsTaskDialog = findOffsets(buffer, "TaskDialogIndirect").length > 0;
if (missing.length === 0) {
return {
ok: true,
reason: `已内嵌 Common-Controls v6 清单(.rsrc ${resource.rawSize} 字节,文件 ${sizeMb}MB${
importsTaskDialog ? ",且静态导入了 TaskDialogIndirect" : ""
})`,
};
}
const detail = [
`内嵌清单缺少:${missing.map((marker) => JSON.stringify(marker)).join(", ")}`,
importsTaskDialog
? "该 exe 静态导入了 comctl32!TaskDialogIndirect(gpui 的 Windows prompt)," +
"而 Windows 只会给声明了 Common-Controls v6 依赖的进程加载 v6,所以它会在 main 之前" +
"以 0xC0000139 退出(现象:弹「无法定位程序输入点 TaskDialogIndirect …」)。"
: "清单不完整,comctl32 v6 的激活上下文会失效。",
"修法:main/build.rs 里 set_version_info(VersionInfo::FILETYPE, 1) + " +
"set_manifest(include_str!(\"../resources/windows/navop.manifest.xml\"))," +
"清单内容见 resources/windows/navop.manifest.xml。",
].join(" ");
return { ok: false, reason: detail };
}
const files = process.argv.slice(2);
if (files.length === 0) {
console.error("用法: node script/validate-windows-exe.mjs <navop.exe> [more exes...]");
process.exit(2);
}
let failed = false;
for (const file of files) {
let result;
try {
result = validate(file);
} catch (error) {
result = { ok: false, reason: error.message };
}
if (result.ok) {
console.log(`OK ${file}: ${result.reason}`);
} else {
failed = true;
console.error(`FAIL ${file}: ${result.reason}`);
}
}
process.exit(failed ? 1 : 0);