9cef99f0ff
- default files []: resolve sync list from patch-manifest keys, else discover release attachments (exclude launcher artifacts). - Explicit files[] still overrides; strip deprecated Wow-patched.exe on merge. - listReleaseAttachmentNames + fetchGiteaReleaseRecord helpers. - Version 1.0.7; README config docs. Co-authored-by: Cursor <cursoragent@cursor.com>
151 lines
4.7 KiB
JavaScript
151 lines
4.7 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { createHash } = require('node:crypto');
|
|
const { downloadReleaseAsset, downloadGitHubRepoFile } = require('./github');
|
|
|
|
async function sha256File(absPath) {
|
|
const buf = await fs.readFile(absPath);
|
|
return createHash('sha256').update(buf).digest('hex');
|
|
}
|
|
|
|
function stateDir(gameDir) {
|
|
return path.join(gameDir, '.fractured');
|
|
}
|
|
|
|
function statePath(gameDir) {
|
|
return path.join(stateDir(gameDir), 'patch-state.json');
|
|
}
|
|
|
|
async function readPatchState(gameDir) {
|
|
if (!gameDir) return null;
|
|
try {
|
|
const t = await fs.readFile(statePath(gameDir), 'utf8');
|
|
return JSON.parse(t);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function writePatchState(gameDir, manifestVersion, fileShas) {
|
|
const p = statePath(gameDir);
|
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
const body = {
|
|
client_build: manifestVersion,
|
|
updated_at: new Date().toISOString(),
|
|
files: fileShas,
|
|
};
|
|
const tmp = p + '.tmp';
|
|
await fs.writeFile(tmp, JSON.stringify(body, null, 2), 'utf8');
|
|
await fs.rename(tmp, p);
|
|
}
|
|
|
|
function validateManifest(m) {
|
|
if (!m || m.version == null || String(m.version).trim() === '') return false;
|
|
if (!m.files || typeof m.files !== 'object') return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Download and parse patch-manifest.json (or custom name). Returns null on any failure.
|
|
*/
|
|
async function loadManifest(cfg) {
|
|
const pm = cfg.patch_manifest;
|
|
if (!pm || !pm.enabled || !String(pm.source || '').trim()) return null;
|
|
const tmp = path.join(os.tmpdir(), `fr-patch-manifest-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
|
try {
|
|
if (pm.from_release) {
|
|
await downloadReleaseAsset(cfg, String(pm.source).trim(), tmp);
|
|
} else {
|
|
await downloadGitHubRepoFile(cfg, String(pm.source).trim(), tmp);
|
|
}
|
|
const raw = await fs.readFile(tmp, 'utf8');
|
|
await fs.unlink(tmp).catch(() => {});
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
await fs.unlink(tmp).catch(() => {});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True if every from_release file on disk matches manifest sha256.
|
|
*/
|
|
async function patchesMatchManifest(cfg, manifest, onStatus) {
|
|
if (!validateManifest(manifest)) return false;
|
|
const { buildResolvedReleaseFiles } = require('./release-sync');
|
|
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
|
const gameDir = cfg.game_dir;
|
|
for (const entry of entries) {
|
|
if (!entry.from_release) continue;
|
|
const spec = manifest.files[entry.source];
|
|
if (!spec || !spec.sha256) return false;
|
|
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
|
const destAbs = path.join(gameDir, ...parts);
|
|
let disk;
|
|
try {
|
|
disk = await sha256File(destAbs);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (disk.toLowerCase() !== String(spec.sha256).trim().toLowerCase()) return false;
|
|
}
|
|
if (onStatus) {
|
|
onStatus(`Client files already match build ${manifest.version} (nothing to download).`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function verifyInstalledAgainstManifest(cfg, manifest) {
|
|
if (!validateManifest(manifest)) return;
|
|
const { buildResolvedReleaseFiles } = require('./release-sync');
|
|
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
|
for (const entry of entries) {
|
|
if (!entry.from_release) continue;
|
|
const spec = manifest.files[entry.source];
|
|
if (!spec || !spec.sha256) {
|
|
throw new Error(
|
|
`patch-manifest.json is missing a sha256 for "${entry.source}" — regenerate the manifest for this release.`
|
|
);
|
|
}
|
|
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
|
const destAbs = path.join(cfg.game_dir, ...parts);
|
|
const disk = await sha256File(destAbs);
|
|
if (disk.toLowerCase() !== String(spec.sha256).trim().toLowerCase()) {
|
|
throw new Error(
|
|
`${entry.source}: checksum mismatch after install (expected ${spec.sha256.slice(0, 12)}…, got ${disk.slice(0, 12)}…). Try syncing again.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function recordPatchState(cfg, manifest) {
|
|
if (!validateManifest(manifest)) return;
|
|
const { buildResolvedReleaseFiles } = require('./release-sync');
|
|
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
|
const shas = {};
|
|
for (const entry of entries) {
|
|
if (!entry.from_release) continue;
|
|
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
|
const destAbs = path.join(cfg.game_dir, ...parts);
|
|
try {
|
|
shas[entry.source] = await sha256File(destAbs);
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
await writePatchState(cfg.game_dir, String(manifest.version), shas);
|
|
}
|
|
|
|
module.exports = {
|
|
loadManifest,
|
|
validateManifest,
|
|
patchesMatchManifest,
|
|
verifyInstalledAgainstManifest,
|
|
recordPatchState,
|
|
readPatchState,
|
|
statePath,
|
|
};
|