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

188 lines
11 KiB
JavaScript

import { windowsUpdateInstallerRunning } from "./native-update-windows.mjs";
import { createServer } from "node:http";
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, createReadStream, statSync, renameSync } from "node:fs";
import { join, resolve, dirname, basename } from "node:path";
if (process.env.GITHUB_ACTIONS !== "true") throw new Error("Release bridge runs only on disposable CI runners");
const root = resolve(process.argv[2]);
const previous = join(root, "previous");
const candidate = join(root, "candidate");
const evidence = join(root, "evidence");
const installed = join(root, "installed");
const profile = join(root, "profile");
for (const path of [evidence, installed, profile]) mkdirSync(path, { recursive: true });
// Acknowledge only the isolated installation's first-run confirmation.
writeFileSync(join(profile, "installation-confirmed"), new Date().toISOString());
const candidateName = readdirSync(candidate).find((name) => process.platform === "darwin" ? name.endsWith(".zip") : name.endsWith(".exe"));
const version = /EdgeEver-(\d+\.\d+\.\d+)-/.exec(candidateName)?.[1];
if (!version) throw new Error("Candidate installer version missing");
const digest = (path) => createHash("sha256").update(readFileSync(path)).digest("hex");
const wait = (ms) => new Promise((yes) => setTimeout(yes, ms));
const until = async (fn, description) => {
const deadline = Date.now() + 240_000;
while (Date.now() < deadline) { const value = await fn(); if (value) return value; await wait(200); }
throw new Error(`Timed out: ${description}`);
};
let executable, expectedAsar;
if (process.platform === "darwin") {
const oldZip = readdirSync(previous).find((name) => name.endsWith(".zip"));
for (const [archive, destination] of [[join(previous, oldZip), installed], [join(candidate, candidateName), join(root, "expected")]]) {
const result = spawnSync("ditto", ["-x", "-k", archive, destination], { encoding: "utf8" });
if (result.status !== 0) throw new Error(result.stderr);
}
executable = join(installed, "EdgeEver.app/Contents/MacOS/EdgeEver");
expectedAsar = join(root, "expected/EdgeEver.app/Contents/Resources/app.asar");
const path = join(candidate, candidateName);
const checksum = createHash("sha512").update(readFileSync(path)).digest("base64");
writeFileSync(join(candidate, "latest-mac.yml"), `version: ${version}\nfiles:\n - url: ${candidateName}\n sha512: ${checksum}\n size: ${statSync(path).size}\npath: ${candidateName}\nsha512: ${checksum}\n`);
} else {
const oldExe = readdirSync(previous).find((name) => name.endsWith(".exe"));
console.log("Installing published Windows predecessor into the isolated directory");
const result = spawnSync(join(previous, oldExe), ["/S", `/D=${installed}`], {
stdio: "ignore", timeout: 120_000, windowsHide: true,
env: { ...process.env, EDGE_EVER_DISABLE_AUTO_UPDATE: "1" },
});
if (result.status !== 0) throw new Error(`Old release NSIS installation failed: ${result.status}`);
executable = join(installed, "EdgeEver.exe");
spawnSync("powershell", ["-NoProfile", "-Command", `Get-CimInstance Win32_Process -Filter "Name='EdgeEver.exe'" | Where-Object { $_.ExecutablePath -eq $env:BRIDGE_INSTALLED_EXE } | ForEach-Object { taskkill /PID $_.ProcessId /T /F | Out-Null }`], {
stdio: "ignore", env: { ...process.env, BRIDGE_INSTALLED_EXE: executable }, timeout: 30_000,
});
console.log("Published Windows predecessor installed");
// The updated application's version plus updater-verified signed installer
// hashes establish provenance. The bridge does not extract/repack NSIS.
}
const logPath = join(profile, "logs/desktop.log");
const installedAsar = process.platform === "darwin" ? join(dirname(executable), "../Resources/app.asar") : join(installed, "resources/app.asar");
const originalAsarDigest = digest(installedAsar);
const server = createServer((request, response) => {
const name = basename(new URL(request.url, "http://127.0.0.1").pathname);
const allowed = [candidateName, "latest-mac.yml", "latest.yml", "latest-windows.json", "latest-windows.json.sig"];
if (!allowed.includes(name) || !existsSync(join(candidate, name))) { response.writeHead(404).end(); return; }
const path = join(candidate, name);
response.writeHead(200, { "Content-Length": statSync(path).size });
createReadStream(path).pipe(response);
});
await new Promise((yes) => server.listen(0, "127.0.0.1", yes));
const feed = `http://127.0.0.1:${server.address().port}/`;
const children = [];
const launch = async (update) => {
let endpoint;
const child = spawn(executable, ["--inspect=127.0.0.1:0", `--user-data-dir=${profile}`], {
env: { ...process.env, EDGE_EVER_DISABLE_AUTO_UPDATE: update ? "0" : "1" }, stdio: ["ignore", "pipe", "pipe"],
});
children.push(child);
for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk) => {
const path = join(evidence, update ? "old-process.log" : "new-process.log");
writeFileSync(path, chunk, { flag: "a" });
endpoint ||= /Debugger listening on (ws:\/\/[^\s]+)/.exec(String(chunk))?.[1];
});
await until(() => endpoint, "isolated loopback main-process debugger");
const socket = new WebSocket(endpoint);
await new Promise((yes, no) => { socket.addEventListener("open", yes, { once: true }); socket.addEventListener("error", no, { once: true }); });
let serial = 0;
const pending = new Map();
socket.addEventListener("message", ({ data }) => {
const message = JSON.parse(String(data));
if (message.id && pending.has(message.id)) { const callbacks = pending.get(message.id); pending.delete(message.id); if (message.error) callbacks.reject(new Error(JSON.stringify(message.error))); else callbacks.resolve(message.result); }
});
const send = (method, params = {}) => new Promise((resolveCall, reject) => {
const id = ++serial;
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Debugger command timed out: ${method}`)); }, 20_000);
pending.set(id, {
resolve: (value) => { clearTimeout(timer); resolveCall(value); },
reject: (error) => { clearTimeout(timer); reject(error); },
});
socket.send(JSON.stringify({ id, method, params }));
});
// The inspector can accept connections before Electron registers its built-in
// module. Wait for that registration without pausing native startup.
await until(async () => {
const ready = await send("Runtime.evaluate", {
expression: `(() => { try { return !!process.getBuiltinModule('module').createRequire(process.resourcesPath + '/app.asar/package.json')('electron').app; } catch { return false; } })()`,
returnByValue: true,
});
return ready.result?.value === true;
}, "Electron main-process module registration");
console.log(`Configuring ${update ? "old" : "updated"} application through its isolated debugger`);
const expression = `(() => {
const req = process.getBuiltinModule('module').createRequire(process.resourcesPath + '/app.asar/package.json');
const { app, net, dialog } = req('electron');
app.isInApplicationsFolder = () => true;
app.on('web-contents-created', (_event, contents) => {
contents.on('console-message', (...args) => {
const details = args.find(value => value && typeof value === 'object' && typeof value.message === 'string');
const message = details?.message ?? args.filter(value => typeof value === 'string').join(' ');
req('node:fs').appendFileSync(${JSON.stringify(join(evidence, update ? "old-renderer.jsonl" : "new-renderer.jsonl"))}, JSON.stringify({message}) + '\\n');
});
});
const configure = () => {
req('node:fs').writeFileSync(${JSON.stringify(join(evidence, update ? "old-version.json" : "new-version.json"))}, JSON.stringify({version: app.getVersion(), executable: process.execPath}));
if (${JSON.stringify(update)}) {
const { autoUpdater } = req('electron-updater');
autoUpdater.setFeedURL({provider:'generic',url:${JSON.stringify(feed)}});
autoUpdater.disableDifferentialDownload = true;
const fetch = net.fetch.bind(net);
net.fetch = (url, options) => {
const value = String(url);
const prefix = 'https://github.com/tianma-if/edgeever/releases/download/v${version}/';
return fetch(value.startsWith(prefix) ? ${JSON.stringify(feed)} + value.slice(prefix.length) : url, options);
};
const install = autoUpdater.quitAndInstall.bind(autoUpdater);
autoUpdater.quitAndInstall = (silent) => { autoUpdater.autoRunAppAfterInstall = false; return install(silent, false); };
dialog.showMessageBox = async (...args) => {
const options = args.at(-1);
return {response: options.buttons?.some(button => /restart|重启/i.test(button)) ? 0 : 1, checkboxChecked:false};
};
}
};
if (app.isReady()) configure(); else app.once('ready', configure);
return true;
})()`;
console.log("Applying feed selection and dialog automation; package bytes and trust remain unchanged");
const evaluated = await send("Runtime.evaluate", { expression, returnByValue: true });
if (evaluated.exceptionDetails) throw new Error(JSON.stringify(evaluated.exceptionDetails));
console.log("Application startup continues without a debugger pause");
socket.close(); // Node must not wait for an attached debugger during quit.
return child;
};
const result = { platform: process.platform, arch: process.arch, candidateVersion: version, candidateRun: process.env.CANDIDATE_RUN };
try {
await launch(true);
await until(() => {
try { return digest(installedAsar) !== originalAsarDigest && (!expectedAsar || digest(installedAsar) === digest(expectedAsar)); } catch { return false; }
}, "official old release installs the real candidate payload");
await until(() => children[0].exitCode !== null, "old release exits");
if (process.platform === "win32") {
// NSIS copies app.asar before renderer resources. Wait for the installer,
// not just a changed ASAR, before launching the updated application.
await until(() => !windowsUpdateInstallerRunning(candidateName), "Windows installer completes all resource copies");
await wait(1_000);
}
if (existsSync(logPath)) renameSync(logPath, join(evidence, "old-desktop.log"));
await launch(false);
await until(() => {
if (!existsSync(logPath)) return false;
const value = readFileSync(logPath, "utf8");
return value.includes('"renderer.bootstrap-ready"') && value.includes('"sidecar.ready"');
}, "updated application opens the same isolated user profile");
const newVersion = JSON.parse(readFileSync(join(evidence, "new-version.json"), "utf8"));
if (newVersion.version !== version) throw new Error(`Installed version mismatch: ${newVersion.version}`);
result.ok = true;
result.old = JSON.parse(readFileSync(join(evidence, "old-version.json"), "utf8"));
result.new = newVersion;
result.installedAsarSha256 = digest(installedAsar);
} catch (error) { result.ok = false; result.error = error.stack; process.exitCode = 1; }
finally {
if (existsSync(logPath)) writeFileSync(join(evidence, "new-desktop.log"), readFileSync(logPath));
writeFileSync(join(evidence, "result.json"), JSON.stringify(result, null, 2));
console.log(JSON.stringify(result, null, 2));
for (const child of children) {
if (child.exitCode !== null) continue;
if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
else child.kill("SIGTERM");
}
server.closeAllConnections(); server.close();
}