'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 gameDir = cfg.game_dir; for (const entry of cfg.files || []) { 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; for (const entry of cfg.files || []) { 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 shas = {}; for (const entry of cfg.files || []) { 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, };