Files
Docker Build 9cef99f0ff feat(launcher): sync release assets from manifest or attachment list (no fixed exe name)
- 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>
2026-05-10 22:04:48 -05:00

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,
};