188 lines
11 KiB
JavaScript
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();
|
|
}
|