Files
edgeever/scripts/verify-native-desktop-update.mjs
edgeever-updater[bot] 507cb892ad chore: update EdgeEver from v2.19.0
EdgeEver-Upstream-Commit: a80d9bfda1aad846e20882813758a59fe27bf8ca
2026-11-04 09:13:22 +00:00

261 lines
15 KiB
JavaScript

import { readNativeUpdateStatus } from "./native-update-status.mjs";
import { macAppBundle, macAppExecutable } from "./native-update-macos.mjs";
import { windowsUpdateInstallerRunning, stopWindowsUpdateFixtures } from "./native-update-windows.mjs";
import { createServer } from "node:http";
import { createReadStream, existsSync, readFileSync, statSync, writeFileSync, mkdirSync, chmodSync, appendFileSync } from "node:fs";
import { cp, rm } from "node:fs/promises";
import { spawn, spawnSync } from "node:child_process";
import { join, resolve, basename, dirname } from "node:path";
import { createHash } from "node:crypto";
if (process.env.GITHUB_ACTIONS !== "true" || process.env.GITHUB_REPOSITORY !== "tianma-if/edgeever") {
throw new Error("Native installer verification is restricted to the official disposable CI runners");
}
const root = resolve(process.argv[2]);
const versions = JSON.parse(readFileSync(join(root, "versions.json"), "utf8"));
const [oldVersion, intermediateVersion, latestVersion] = versions;
const evidence = join(root, "evidence");
mkdirSync(evidence, { recursive: true });
const report = { platform: process.platform, arch: process.arch, sourceSha: process.env.GITHUB_SHA, versions,
instrumentation: "Full EdgeEver builds with isolated app identity, local feed, command harness and ephemeral Windows trust key; production update controller and native installers are unchanged.",
fixtureRun: process.env.FIXTURE_RUN || null,
scenarios: [] };
let feedVersion = intermediateVersion;
let corruptSignature = false;
let corruptPackage = false;
const requests = [];
const metadataName = process.platform === "darwin" ? "latest-mac.yml" : process.platform === "win32" ? "latest.yml" : "latest-linux.yml";
const packageName = (version) => process.platform === "darwin" ? `EdgeEver-${version}-mac-${process.arch}.zip`
: process.platform === "win32" ? `EdgeEver-${version}-windows-x64.exe` : `EdgeEver-${version}-linux-x64.AppImage`;
const server = createServer((request, response) => {
const pathname = new URL(request.url, "http://127.0.0.1").pathname;
const parts = pathname.split("/").filter(Boolean);
let version = feedVersion;
let name;
if (parts[0] === "feed" && parts.length === 2) name = parts[1];
else if (parts[0] === "release" && parts.length === 3 && versions.includes(parts[1])) {
version = parts[1]; name = parts[2];
}
if (!name || basename(name) !== name || ![metadataName, "latest-windows.json", "latest-windows.json.sig", packageName(version)].includes(name)) {
response.writeHead(404).end(); return;
}
const path = join(root, version, name);
if (!existsSync(path)) { response.writeHead(404).end(); return; }
requests.push({ at: new Date().toISOString(), version, name });
if (corruptSignature && name === "latest-windows.json.sig") {
const signature = JSON.parse(readFileSync(path, "utf8"));
signature.signature = Buffer.alloc(64).toString("base64");
response.writeHead(200).end(JSON.stringify(signature)); return;
}
response.writeHead(200, { "Content-Length": statSync(path).size, "Content-Type": name.endsWith(".yml") ? "text/yaml" : "application/octet-stream" });
if (corruptPackage && name === packageName(version)) {
const bytes = readFileSync(path);
bytes[bytes.length - 1] ^= 1;
response.end(bytes); return;
}
createReadStream(path).pipe(response);
});
await new Promise((yes, no) => { server.once("error", no); server.listen(0, "127.0.0.1", yes); });
const feed = `http://127.0.0.1:${server.address().port}`;
let commandId = 0;
const children = [];
const readEvents = (path) => !existsSync(path) ? [] : readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
const wait = (ms) => new Promise((yes) => setTimeout(yes, ms));
const until = async (predicate, description, timeout = 180_000) => {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const value = predicate();
if (value) return value;
await wait(200);
}
throw new Error(`Timed out: ${description}`);
};
const installBaseline = async (directory) => {
const installed = join(directory, "installed");
mkdirSync(installed, { recursive: true });
if (process.platform === "darwin") {
const unpack = spawnSync("ditto", ["-x", "-k", join(root, oldVersion, packageName(oldVersion)), installed], { encoding: "utf8" });
if (unpack.status !== 0) throw new Error(unpack.stderr);
return macAppExecutable(macAppBundle(installed));
}
if (process.platform === "win32") {
stopWindowsUpdateFixtures();
const installer = join(root, oldVersion, packageName(oldVersion));
let result;
for (let attempt = 0; attempt < 3; attempt++) {
result = spawnSync(installer, ["/S", `/D=${installed}`], {
stdio: "ignore", windowsHide: true, timeout: 120_000,
env: { ...process.env, EDGE_EVER_DISABLE_AUTO_UPDATE: "1" },
});
if (result.status === 0) break;
stopWindowsUpdateFixtures();
await wait(2_000);
}
if (result.status !== 0) throw new Error(`Baseline NSIS install failed: ${result.status}`);
const executable = join(installed, "EdgeEverUpdateVerifier.exe");
spawnSync("powershell", ["-NoProfile", "-Command", `Get-CimInstance Win32_Process -Filter "Name='EdgeEverUpdateVerifier.exe'" | Where-Object { $_.ExecutablePath -eq $env:VERIFIER_INSTALLED_EXE } | ForEach-Object { taskkill /PID $_.ProcessId /T /F | Out-Null }`], {
stdio: "ignore", env: { ...process.env, VERIFIER_INSTALLED_EXE: executable }, timeout: 30_000,
});
return executable;
}
const appImage = join(installed, "EdgeEver.AppImage");
await cp(join(root, oldVersion, packageName(oldVersion)), appImage);
chmodSync(appImage, 0o755);
return appImage;
};
const stopFixture = async (events) => {
// Only PIDs written by the isolated test app are eligible for termination.
for (const entry of events.filter((value) => value.event === "boot")) {
if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(entry.pid), "/T", "/F"], { stdio: "ignore" });
else {
const current = spawnSync("ps", ["-p", String(entry.pid), "-o", "command="], { encoding: "utf8" });
if (current.stdout?.trim().startsWith(entry.executable)) {
spawnSync("pkill", ["-KILL", "-P", String(entry.pid)], { stdio: "ignore" });
try { process.kill(entry.pid, "SIGKILL"); } catch {}
}
}
}
};
try {
for (const scenario of ["install-latest", "quit-latest", "reject-corrupt-update"]) {
const directory = join(evidence, scenario);
mkdirSync(directory, { recursive: true });
feedVersion = intermediateVersion;
const eventsPath = join(directory, "events.jsonl");
const commandPath = join(directory, "command.json");
const profile = join(directory, "profile");
mkdirSync(profile, { recursive: true });
const env = { ...process.env,
EDGE_EVER_UPDATE_VERIFIER_PROFILE: profile, EDGE_EVER_UPDATE_VERIFIER_LOG: eventsPath,
EDGE_EVER_UPDATE_VERIFIER_COMMAND: commandPath, EDGE_EVER_UPDATE_VERIFIER_FEED: feed,
EDGE_EVER_UPDATE_VERIFIER_PUBLIC_KEY: join(root, "public-key.pem"),
};
// Squirrel/LaunchServices can relaunch without inheriting environment.
// Every signed fixture embeds only this fixed config path, never secrets.
writeFileSync(join(root, "runtime.json"), JSON.stringify({
EDGE_EVER_UPDATE_VERIFIER_PROFILE: profile, EDGE_EVER_UPDATE_VERIFIER_LOG: eventsPath,
EDGE_EVER_UPDATE_VERIFIER_COMMAND: commandPath, EDGE_EVER_UPDATE_VERIFIER_FEED: feed,
EDGE_EVER_UPDATE_VERIFIER_PUBLIC_KEY: join(root, "public-key.pem"),
}));
const executable = await installBaseline(directory);
let debuggerEndpoint;
const launch = () => {
if (!existsSync(executable)) throw new Error(`Installed fixture executable missing: ${executable}`);
const child = spawn(executable, ["--inspect=127.0.0.1:0"], { env, stdio: ["ignore", "pipe", "pipe"] });
child.on("error", (error) => appendFileSync(join(directory, "process.log"), `${error.stack}\n`));
child.stdout.on("data", (data) => appendFileSync(join(directory, "process.log"), data));
child.stderr.on("data", (data) => {
appendFileSync(join(directory, "process.log"), data);
debuggerEndpoint ||= /Debugger listening on (ws:\/\/[^\s]+)/.exec(String(data))?.[1];
});
children.push(child);
return child;
};
const events = () => readEvents(eventsPath);
const command = async (action) => {
const id = ++commandId;
writeFileSync(commandPath, JSON.stringify({ id, action }));
return until(() => events().find((entry) => entry.id === id), `command ${action}`);
};
try {
launch();
await until(() => {
const failure = readEvents(join(profile, "logs/desktop.log")).find((entry) => entry.event === "main.startup-failed");
if (failure) throw new Error(`Fixture startup failed: ${failure.message}`);
return events().some((entry) => entry.event === "ready" && entry.version === intermediateVersion);
}, "intermediate package downloaded");
feedVersion = latestVersion;
if (scenario === "reject-corrupt-update") {
if (process.platform === "win32") corruptSignature = true;
else corruptPackage = true;
const result = await command("check");
const diagnostics = readEvents(join(profile, "logs/desktop.log"));
if (!diagnostics.some((entry) => entry.event === "update.check-failed" || entry.event === "update.download-failed")) {
throw new Error(`Corrupt metadata/package was not rejected: ${JSON.stringify(result)}`);
}
if (diagnostics.some((entry) => entry.event === "update.install-confirmed")) throw new Error("An invalid update triggered installation");
corruptSignature = false;
corruptPackage = false;
await command("check");
await until(() => events().some((entry) => entry.event === "ready" && entry.version === latestVersion), "valid replacement recovers after corruption");
}
if (scenario === "quit-latest") {
await command("quit");
await until(() => {
const log = readEvents(join(profile, "logs/desktop.log"));
return log.some((entry) => entry.event === "session.quit");
}, "normal application shutdown");
const digest = (path) => createHash("sha256").update(readFileSync(path)).digest("hex");
const expectedPayload = () => process.platform === "darwin"
? join(macAppBundle(join(root, latestVersion, process.arch === "arm64" ? "mac-arm64" : "mac")), "Contents/Resources/app.asar")
: process.platform === "win32" ? join(root, latestVersion, "win-unpacked/resources/app.asar")
: join(root, latestVersion, packageName(latestVersion));
const installedPayload = process.platform === "darwin" ? join(dirname(executable), "../Resources/app.asar")
: process.platform === "win32" ? join(dirname(executable), "resources/app.asar") : executable;
const recordedDigest = join(root, latestVersion, "payload.sha256");
const expectedDigest = existsSync(recordedDigest) ? readFileSync(recordedDigest, "utf8").trim() : digest(expectedPayload());
await until(() => {
try { return digest(installedPayload) === expectedDigest; } catch { return false; }
}, "native installation replaces the actual installed payload", 240_000);
if (process.platform === "win32") {
await until(() => !windowsUpdateInstallerRunning(packageName(latestVersion)), "Windows native installer completes");
}
await wait(1_000);
if (events().filter((entry) => entry.event === "boot").length !== 1) throw new Error("Normal quit relaunched the application");
// Confirm the installed executable itself now contains the new version.
launch();
} else {
const installation = await command("install");
if (installation.status?.state === "idle" || installation.event === "command-error") {
const status = await readNativeUpdateStatus(debuggerEndpoint).catch((error) => ({ error: error.message }));
writeFileSync(join(directory, "native-status.json"), JSON.stringify(status, null, 2));
throw new Error(`Native installation failed: ${JSON.stringify(status)}`);
}
}
await until(() => events().some((entry) => entry.event === "boot" && entry.version === latestVersion), "latest installed application launches", 240_000);
await until(() => {
const entries = readEvents(join(profile, "logs/desktop.log"));
const lastSession = entries.findLastIndex((entry) => entry.event === "session.started");
const currentSession = entries.slice(lastSession);
return currentSession.some((entry) => entry.event === "renderer.bootstrap-ready") && currentSession.some((entry) => entry.event === "sidecar.ready");
}, "latest installed renderer and sidecar are ready");
const diagnostics = readEvents(join(profile, "logs/desktop.log"));
const installs = diagnostics.filter((entry) => entry.event === "update.install-confirmed");
const boots = events().filter((entry) => entry.event === "boot").map((entry) => entry.version);
if (boots.includes(intermediateVersion) || installs.length !== 1 || installs[0].version !== latestVersion) {
throw new Error(`Unexpected installation sequence: ${JSON.stringify({ boots, installs })}`);
}
if (process.platform === "linux") {
const digest = (path) => createHash("sha256").update(readFileSync(path)).digest("hex");
if (digest(executable) !== digest(join(root, latestVersion, packageName(latestVersion)))) throw new Error("Installed AppImage digest differs from the selected release");
}
report.scenarios.push({ scenario, ok: true, boots, installs, executable });
writeFileSync(join(directory, "result.json"), JSON.stringify(report.scenarios.at(-1), null, 2));
} finally {
await stopFixture(events());
if (process.platform === "win32") {
stopWindowsUpdateFixtures();
await until(() => !windowsUpdateInstallerRunning(packageName(latestVersion)), "previous fixture installer releases its process lock");
}
await wait(1_000);
}
}
report.ok = true;
} catch (error) {
report.ok = false;
report.error = error.stack;
process.exitCode = 1;
} finally {
writeFileSync(join(evidence, "requests.json"), JSON.stringify(requests, null, 2));
writeFileSync(join(evidence, "result.json"), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
for (const child of children) { if (child.exitCode === null) child.kill("SIGKILL"); }
server.closeAllConnections();
server.close();
// Keep logs; remove installed fixture apps and anonymous databases from artifacts.
for (const scenario of ["install-latest", "quit-latest", "reject-corrupt-update"]) {
await rm(join(evidence, scenario, "installed"), { recursive: true, force: true }).catch(() => {});
}
}