145 lines
5.8 KiB
JavaScript
145 lines
5.8 KiB
JavaScript
// 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 = "mhgmh";
|
||
const REPO = "codex-market-app";
|
||
const API = `https://update.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;
|
||
const fullAppVersion = manifest.fullAppVersion || "";
|
||
// 兼容协议注册等必须由完整安装包完成的场景:
|
||
// 旧版先安装热更,热更生效后再引导安装同版本完整包。
|
||
const needFullAfterHot = !!manifest.requiresFullAfterHot &&
|
||
(!fullAppVersion || cmp(appVersion, fullAppVersion) < 0);
|
||
return {
|
||
hasHot: newer,
|
||
needFull: newer && needFull, // 有新内容但当前原生版本不够 → 必须下完整包
|
||
canHot: newer && minOk && !manifest.requiresFull,
|
||
needFullAfterHot,
|
||
contentVersion: manifest.contentVersion,
|
||
fullAppVersion: fullAppVersion || null,
|
||
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 };
|