Files

145 lines
5.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };