fix(desktop): harden Windows startup validation

This commit is contained in:
tianma-if committed 2026-08-30 18:53:57 +08:00
1 parent 10b50829ee
commit 263fafd433
19 files changed
+437 -17

No files matched your search

+9
View File
@@ -4,6 +4,8 @@ import { describe, expect, test } from "bun:test";
const workflow = readFileSync(new URL("../.github/workflows/desktop-build.yml", import.meta.url), "utf8");
const mobileWorkflow = readFileSync(new URL("../.github/workflows/mobile-build.yml", import.meta.url), "utf8");
const desktopPackageVerifier = readFileSync(new URL("./verify-desktop-package.mjs", import.meta.url), "utf8");
const packagedStartupVerifier = readFileSync(new URL("./verify-packaged-desktop-startup.mjs", import.meta.url), "utf8");
const cargoConfig = readFileSync(new URL("../.cargo/config.toml", import.meta.url), "utf8");
function step(name) {
const start = workflow.indexOf(` - name: ${name}\n`);
@@ -83,6 +85,13 @@ describe("desktop release workflow", () => {
expect(workflow).toContain("allow-missing-windows-signature");
expect(workflow).toContain("name: Audit signed Windows update");
expect(workflow).toContain("verify-windows-update-release.mjs");
expect(workflow).toContain("name: Run packaged Windows sidecar integration tests");
expect(workflow).toContain("name: Verify packaged Windows first launch");
expect(workflow).toContain("verify:packaged-desktop-startup");
expect(packagedStartupVerifier).toContain('new Set(["sidecar.ready", "renderer.bootstrap-ready"])');
expect(desktopPackageVerifier).toContain("isVisualCppRuntimeDll");
expect(cargoConfig).toContain('target.x86_64-pc-windows-msvc');
expect(cargoConfig).toContain('target-feature=+crt-static');
expect(desktopPackageVerifier).toContain(
'path.replaceAll("\\\\", "/")',
);
+69
View File
@@ -0,0 +1,69 @@
import { readFileSync } from "node:fs";
const readCString = (bytes, offset, maxLength = 260) => {
const endLimit = Math.min(bytes.length, offset + maxLength);
let end = offset;
while (end < endLimit && bytes[end] !== 0) end += 1;
if (end === endLimit) throw new Error("PE import name is not null-terminated");
return bytes.toString("ascii", offset, end);
};
export const readPeImportedDlls = (input) => {
const bytes = Buffer.isBuffer(input) ? input : readFileSync(input);
if (bytes.length < 0x40 || bytes.toString("ascii", 0, 2) !== "MZ") throw new Error("Invalid PE DOS header");
const peOffset = bytes.readUInt32LE(0x3c);
if (peOffset + 24 > bytes.length || bytes.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0") {
throw new Error("Invalid PE signature");
}
const coffOffset = peOffset + 4;
const sectionCount = bytes.readUInt16LE(coffOffset + 2);
const optionalHeaderSize = bytes.readUInt16LE(coffOffset + 16);
const optionalHeaderOffset = coffOffset + 20;
const optionalHeaderMagic = bytes.readUInt16LE(optionalHeaderOffset);
const dataDirectoryOffset = optionalHeaderOffset + (optionalHeaderMagic === 0x20b ? 112 : optionalHeaderMagic === 0x10b ? 96 : 0);
if (!dataDirectoryOffset || dataDirectoryOffset + 16 > bytes.length) throw new Error("Unsupported PE optional header");
const importTableRva = bytes.readUInt32LE(dataDirectoryOffset + 8);
const importTableSize = bytes.readUInt32LE(dataDirectoryOffset + 12);
if (!importTableRva || !importTableSize) return [];
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize;
const sections = [];
for (let index = 0; index < sectionCount; index += 1) {
const offset = sectionTableOffset + index * 40;
if (offset + 40 > bytes.length) throw new Error("PE section table is truncated");
sections.push({
virtualSize: bytes.readUInt32LE(offset + 8),
virtualAddress: bytes.readUInt32LE(offset + 12),
rawSize: bytes.readUInt32LE(offset + 16),
rawOffset: bytes.readUInt32LE(offset + 20),
});
}
const fileOffsetFromRva = (rva) => {
const section = sections.find(({ virtualAddress, virtualSize, rawSize }) =>
rva >= virtualAddress && rva < virtualAddress + Math.max(virtualSize, rawSize));
if (!section) throw new Error(`PE RVA is outside every section: 0x${rva.toString(16)}`);
const offset = section.rawOffset + (rva - section.virtualAddress);
if (offset >= bytes.length) throw new Error("PE RVA points outside the file");
return offset;
};
const imports = [];
let descriptorOffset = fileOffsetFromRva(importTableRva);
const descriptorLimit = Math.min(bytes.length, descriptorOffset + importTableSize);
while (descriptorOffset + 20 <= descriptorLimit) {
const originalFirstThunk = bytes.readUInt32LE(descriptorOffset);
const nameRva = bytes.readUInt32LE(descriptorOffset + 12);
const firstThunk = bytes.readUInt32LE(descriptorOffset + 16);
if (originalFirstThunk === 0 && nameRva === 0 && firstThunk === 0) break;
if (!nameRva) throw new Error("PE import descriptor has no DLL name");
imports.push(readCString(bytes, fileOffsetFromRva(nameRva)));
descriptorOffset += 20;
}
return imports;
};
export const isVisualCppRuntimeDll = (name) => /^(?:vcruntime|msvcp|concrt)\d[^/\\]*\.dll$/i.test(name);
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { isVisualCppRuntimeDll, readPeImportedDlls } from "./pe-imports.mjs";
const peWithImport = (dllName) => {
const bytes = Buffer.alloc(0x400);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt32LE(0x80, 0x3c);
bytes.write("PE\0\0", 0x80, "ascii");
const coff = 0x84;
bytes.writeUInt16LE(0x8664, coff);
bytes.writeUInt16LE(1, coff + 2);
bytes.writeUInt16LE(0xf0, coff + 16);
const optional = coff + 20;
bytes.writeUInt16LE(0x20b, optional);
bytes.writeUInt32LE(16, optional + 108);
bytes.writeUInt32LE(0x1000, optional + 120);
bytes.writeUInt32LE(40, optional + 124);
const section = optional + 0xf0;
bytes.write(".rdata", section, "ascii");
bytes.writeUInt32LE(0x200, section + 8);
bytes.writeUInt32LE(0x1000, section + 12);
bytes.writeUInt32LE(0x200, section + 16);
bytes.writeUInt32LE(0x200, section + 20);
bytes.writeUInt32LE(0x1040, 0x200);
bytes.writeUInt32LE(0x1080, 0x20c);
bytes.writeUInt32LE(0x1040, 0x210);
bytes.write(`${dllName}\0`, 0x280, "ascii");
return bytes;
};
describe("PE imports", () => {
test("reads imported DLL names from a PE32+ image", () => {
expect(readPeImportedDlls(peWithImport("VCRUNTIME140.dll"))).toEqual(["VCRUNTIME140.dll"]);
});
test("identifies Visual C++ runtime dependencies", () => {
expect(isVisualCppRuntimeDll("VCRUNTIME140.dll")).toBe(true);
expect(isVisualCppRuntimeDll("MSVCP140_1.dll")).toBe(true);
expect(isVisualCppRuntimeDll("KERNEL32.dll")).toBe(false);
});
});
+3
View File
@@ -48,16 +48,19 @@ export const planNativeRelease = (platform, changedFiles) => {
...mobileOnlyDependencyPatches,
])
: new Set([
".cargo/config.toml",
".github/workflows/desktop-build.yml",
"bun.lock",
"scripts/create-mac-update-metadata.mjs",
"scripts/create-windows-update-metadata.mjs",
"scripts/desktop-icns.mjs",
"scripts/prepare-desktop-icons.mjs",
"scripts/pe-imports.mjs",
"scripts/run-desktop-builder.mjs",
"scripts/sign-windows-update-manifest.mjs",
"scripts/verify-windows-update-release.mjs",
"scripts/verify-desktop-package.mjs",
"scripts/verify-packaged-desktop-startup.mjs",
]);
const relevantChanges = runtimeChangedFiles.filter(
+3
View File
@@ -69,14 +69,17 @@ describe("native release planning", () => {
test("rebuilds desktop when its architecture packaging pipeline changes", () => {
const changedFiles = [
".cargo/config.toml",
".github/workflows/desktop-build.yml",
"scripts/create-mac-update-metadata.mjs",
"scripts/create-windows-update-metadata.mjs",
"scripts/prepare-desktop-icons.mjs",
"scripts/desktop-icns.mjs",
"scripts/pe-imports.mjs",
"scripts/run-desktop-builder.mjs",
"scripts/sign-windows-update-manifest.mjs",
"scripts/verify-windows-update-release.mjs",
"scripts/verify-packaged-desktop-startup.mjs",
];
expect(planNativeRelease("desktop", changedFiles)).toEqual({
rebuild: true,
+7
View File
@@ -4,6 +4,7 @@ import { existsSync, openSync, closeSync, readFileSync, readSync, readdirSync, r
import { basename, join } from "node:path";
import { listPackage } from "@electron/asar";
import { assertMacIcnsComplete } from "./desktop-icns.mjs";
import { isVisualCppRuntimeDll, readPeImportedDlls } from "./pe-imports.mjs";
const outputDirectory = join(process.cwd(), "release", "desktop");
const version = JSON.parse(
@@ -101,6 +102,12 @@ if (requestedPlatform === "darwin") {
assert.ok(existsSync(join(resources, "migrations")), "Windows app bundle is missing migrations");
verifyPeX64(executable, "Electron executable");
verifyPeX64(sidecar, "Rust sidecar");
const sidecarRuntimeImports = readPeImportedDlls(sidecar).filter(isVisualCppRuntimeDll);
assert.deepEqual(
sidecarRuntimeImports,
[],
`Windows sidecar must statically link the Visual C++ runtime; found: ${sidecarRuntimeImports.join(", ")}`,
);
const asarPath = join(resources, "app.asar");
assert.ok(existsSync(asarPath), `Windows app bundle is missing app.asar: ${asarPath}`);
const asarFiles = listAsarFiles(asarPath);
+1 -1
View File
@@ -49,7 +49,7 @@ try {
await verifyEntry({
filePath: entryPath,
readySelector: "#root > *",
readySelector: '[data-edgeever-renderer-ready="true"] #root > *',
label: "Desktop renderer",
});
await verifyEntry({
@@ -0,0 +1,74 @@
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const requestedExecutablePath = process.argv[2]?.trim();
const executablePath = requestedExecutablePath ? resolve(requestedExecutablePath) : "";
if (!executablePath || !existsSync(executablePath) || !statSync(executablePath).isFile()) {
throw new Error(`Packaged desktop executable is missing: ${executablePath || "<not provided>"}`);
}
const userDataDirectory = mkdtempSync(join(tmpdir(), "edgeever-packaged-startup-"));
const diagnosticLogPath = join(userDataDirectory, "logs", "desktop.log");
const output = [];
let processError = null;
const child = spawn(executablePath, [`--user-data-dir=${userDataDirectory}`], {
env: {
...process.env,
EDGE_EVER_DISABLE_AUTO_UPDATE: "1",
},
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
child.on("error", (error) => { processError = error; });
child.stdout.on("data", (chunk) => output.push(chunk.toString()));
child.stderr.on("data", (chunk) => output.push(chunk.toString()));
const timeoutMs = Number(process.env.EDGE_EVER_DESKTOP_STARTUP_TIMEOUT_MS) || 45_000;
const deadline = Date.now() + timeoutMs;
let startupReady = false;
let lastLog = "";
const requiredEvents = new Set(["sidecar.ready", "renderer.bootstrap-ready"]);
const observedEvents = new Set();
try {
while (Date.now() < deadline) {
if (existsSync(diagnosticLogPath)) {
lastLog = readFileSync(diagnosticLogPath, "utf8");
for (const line of lastLog.split(/\r?\n/)) {
try {
const event = JSON.parse(line).event;
if (typeof event === "string") observedEvents.add(event);
} catch { /* Ignore a partial trailing log line. */ }
}
if ([...requiredEvents].every((event) => observedEvents.has(event))) {
startupReady = true;
break;
}
}
if (processError || child.exitCode !== null) break;
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
}
if (!startupReady) {
throw new Error([
`Packaged desktop did not report renderer readiness within ${timeoutMs}ms (exit=${child.exitCode ?? "running"}).`,
`Missing events: ${[...requiredEvents].filter((event) => !observedEvents.has(event)).join(", ") || "none"}`,
processError && `Process error: ${processError.message}`,
lastLog && `Diagnostic log:\n${lastLog}`,
output.length > 0 && `Process output:\n${output.join("")}`,
].filter(Boolean).join("\n\n"));
}
console.log(JSON.stringify({ ok: true, executablePath, events: [...requiredEvents] }));
} finally {
if (child.exitCode === null && child.pid) {
if (process.platform === "win32") {
spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
} else {
child.kill("SIGTERM");
}
}
rmSync(userDataDirectory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}