// 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://up.tokendun.cc/api/v1/repos/${OWNER}/${REPO}`; const HOT_TAG = "hot"; // 可选 ed25519 公钥(SPKI PEM)。留空则只做 sha256 校验;填入后强制校验签名。 const HOT_PUBKEY_PEM = ""; // 语义版本比较:a>b→1, a 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 };