Files
codex-market-app/electron/lib/hotupdate.js
T

138 lines
5.4 KiB
JavaScript
Raw Normal View History

2026-07-15 18:01:56 +08:00
// hotupdate.js —— 热更新:拉取 manifest、下载、校验、解压应用(复用 lib/zip.js)。
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { extractZip } = require("./zip");
const hotstate = require("./hotstate");
const UA = "codex-market";
const OWNER = "backup-bot";
const REPO = "backup";
const API = `https://up.tokendun.cc/api/v1/repos/${OWNER}/${REPO}`;
const HOT_TAG = "hot";
// 可选 ed25519 公钥(SPKI PEM)。留空则只做 sha256 校验;填入后强制校验签名。
const HOT_PUBKEY_PEM = "";
// 语义版本比较:a>b→1, a<b→-1, 相等→0
function cmp(a, b) {
const pa = String(a || "0").replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
const pb = String(b || "0").replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
async function fetchJson(url, ms = 8000) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { "User-Agent": UA }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally { clearTimeout(t); }
}
// 拉取 hot release 里的 hotupdate.json;失败返回 null(断网/无 release 时静默)
async function fetchManifest() {
let rel;
try {
rel = await fetchJson(`${API}/releases/tags/${HOT_TAG}`);
} catch { return null; }
const assets = (rel && rel.assets) || [];
const mAsset = assets.find((a) => a.name === "hotupdate.json");
if (!mAsset) return null;
try {
const m = await fetchJson(mAsset.browser_download_url || `${API.replace("/api/v1/repos", "")}/`);
// 若 bundleUrl 缺失,尝试从同 release 的 asset 里按 contentVersion 找 bundle
if (m && !m.bundleUrl && m.contentVersion) {
const b = assets.find((a) => a.name === `bundle-${m.contentVersion}.zip`);
if (b) m.bundleUrl = b.browser_download_url;
}
return m;
} catch { return null; }
}
// 评估是否有可用热更 / 是否需完整包。
// bundledHotVersion:随包内置的内容版本;appVersion:当前原生版本。
function evaluate(manifest, appVersion, bundledHotVersion, hotBase) {
if (!manifest || !manifest.contentVersion) return { hasHot: false };
const confirmed = hotstate.effectiveVersion(hotBase); // 已确认的热更版本
const effective = cmp(confirmed || "0", bundledHotVersion || "0") > 0 ? confirmed : (bundledHotVersion || "0");
const newer = cmp(manifest.contentVersion, effective) > 0;
const minOk = cmp(appVersion, manifest.minAppVersion || "0") >= 0;
const needFull = !!manifest.requiresFull || !minOk;
return {
hasHot: newer,
needFull: newer && needFull, // 有新内容但当前原生版本不够 → 必须下完整包
canHot: newer && minOk && !manifest.requiresFull,
contentVersion: manifest.contentVersion,
minAppVersion: manifest.minAppVersion || null,
notes: manifest.notes || "",
mandatory: !!manifest.mandatory,
};
}
// 下载 + 校验 + 解压 + 置 target。onProgress(percent 0..100)
async function applyManifest(manifest, hotBase, onProgress) {
if (!manifest || !manifest.bundleUrl || !manifest.sha256) {
throw new Error("热更清单不完整");
}
const ver = manifest.contentVersion;
onProgress && onProgress(1);
const res = await fetch(manifest.bundleUrl, { headers: { "User-Agent": UA } });
if (!res.ok) throw new Error(`下载失败(${res.status})`);
// 带进度读取
const total = Number(res.headers.get("content-length")) || 0;
const reader = res.body.getReader();
const chunks = [];
let got = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
got += value.length;
if (total && onProgress) onProgress(Math.min(99, Math.round((got / total) * 100)));
}
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
// sha256(必做)
const sum = crypto.createHash("sha256").update(buf).digest("hex");
if (sum.toLowerCase() !== String(manifest.sha256).toLowerCase()) {
throw new Error("SHA256 校验失败,已丢弃");
}
// ed25519 签名(可选:仅当内嵌公钥非空)
if (HOT_PUBKEY_PEM) {
if (!manifest.signature) throw new Error("缺少签名");
const ok = crypto.verify(
null,
Buffer.from(`${manifest.sha256}:${ver}`),
HOT_PUBKEY_PEM,
Buffer.from(manifest.signature, "base64")
);
if (!ok) throw new Error("签名校验失败");
}
// 解压到中转目录 → 校验结构 → 原子 rename
fs.mkdirSync(hotBase, { recursive: true });
const tmp = path.join(hotBase, `.tmp-${ver}`);
fs.rmSync(tmp, { recursive: true, force: true });
fs.mkdirSync(tmp, { recursive: true });
extractZip(buf, tmp);
if (!fs.existsSync(path.join(tmp, "electron", "main.js")) ||
!fs.existsSync(path.join(tmp, "renderer", "index.html"))) {
fs.rmSync(tmp, { recursive: true, force: true });
throw new Error("热更包结构不完整");
}
const dst = path.join(hotBase, ver);
fs.rmSync(dst, { recursive: true, force: true });
fs.renameSync(tmp, dst);
hotstate.setTarget(hotBase, ver); // 下次启动生效
onProgress && onProgress(100);
return { ok: true, version: ver };
}
module.exports = { fetchManifest, evaluate, applyManifest, cmp };