mirror of
https://github.com/feigeCode/navop.git
synced 2026-11-06 08:07:31 +00:00
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` 腿会编译这段代码。
141 lines
5.3 KiB
JavaScript
141 lines
5.3 KiB
JavaScript
// 校验 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);
|