fix(launcher): add missing gitea-release and patch-manifest to repo
These modules were required by main.js / auto-update.js / github.js but never committed, so packaged builds lacked them and crashed at startup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { downloadBodyToFile } = require('./http-download');
|
||||||
|
|
||||||
|
function giteaApiBase(cfg) {
|
||||||
|
const base = String(cfg.gitea.base_url || '').trim().replace(/\/+$/, '');
|
||||||
|
return `${base}/api/v1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giteaToken(cfg) {
|
||||||
|
const name = cfg.gitea && cfg.gitea.token_env;
|
||||||
|
if (name && process.env[name]) return String(process.env[name]).trim();
|
||||||
|
return String(process.env.GITEA_TOKEN || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function giteaHeaders(token, json = false) {
|
||||||
|
const h = { 'User-Agent': 'Fractured-Launcher-Electron' };
|
||||||
|
if (json) h.Accept = 'application/json';
|
||||||
|
if (token) h.Authorization = `token ${token}`;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGiteaReleases(cfg) {
|
||||||
|
const g = cfg.gitea;
|
||||||
|
if (!g) return false;
|
||||||
|
return !!(String(g.base_url || '').trim() && String(g.owner || '').trim() && String(g.repo || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
|
||||||
|
const api = giteaApiBase(cfg);
|
||||||
|
const { owner, repo } = cfg.gitea;
|
||||||
|
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
|
||||||
|
const token = giteaToken(cfg);
|
||||||
|
|
||||||
|
let listUrl;
|
||||||
|
if (tag.toLowerCase() === 'latest') {
|
||||||
|
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`;
|
||||||
|
} else {
|
||||||
|
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(listUrl, { headers: giteaHeaders(token, true) });
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let hint = '';
|
||||||
|
if (res.status === 404) hint = ' (wrong tag / no release / check base_url owner repo)';
|
||||||
|
if (res.status === 401 || res.status === 403) hint = ' (set GITEA_TOKEN or gitea.token_env)';
|
||||||
|
throw new Error(`Gitea release ${res.status}${hint}: ${text.slice(0, 600)}`);
|
||||||
|
}
|
||||||
|
const rel = JSON.parse(text);
|
||||||
|
const list = rel.attachments || rel.assets || [];
|
||||||
|
let downloadUrl = '';
|
||||||
|
for (const a of list) {
|
||||||
|
if (a.name !== assetName) continue;
|
||||||
|
downloadUrl = a.browser_download_url || a.download_url || '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!downloadUrl) {
|
||||||
|
const names = list.map((x) => x.name).filter(Boolean);
|
||||||
|
throw new Error(`Gitea release asset "${assetName}" not found; attachments: ${names.join(', ') || '(none)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = { Accept: 'application/octet-stream' };
|
||||||
|
if (token) h.Authorization = `token ${token}`;
|
||||||
|
const dl = await fetch(downloadUrl, { headers: h, redirect: 'follow' });
|
||||||
|
await downloadBodyToFile(dl, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base URL for electron-updater generic provider (expects latest.yml under this path).
|
||||||
|
* Matches Gitea’s pattern: …/owner/repo/releases/download/{tag}/latest.yml
|
||||||
|
*/
|
||||||
|
async function getGiteaUpdaterFeedBase(cfg) {
|
||||||
|
if (!useGiteaReleases(cfg)) return null;
|
||||||
|
const api = giteaApiBase(cfg);
|
||||||
|
const { owner, repo } = cfg.gitea;
|
||||||
|
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
|
||||||
|
const token = giteaToken(cfg);
|
||||||
|
let listUrl;
|
||||||
|
if (tag.toLowerCase() === 'latest') {
|
||||||
|
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`;
|
||||||
|
} else {
|
||||||
|
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(listUrl, { headers: giteaHeaders(token, true) });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const rel = await res.json();
|
||||||
|
const tagName = rel.tag_name;
|
||||||
|
if (!tagName || typeof tagName !== 'string') return null;
|
||||||
|
const root = String(cfg.gitea.base_url || '').trim().replace(/\/+$/, '');
|
||||||
|
const url = `${root}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/download/${encodeURIComponent(tagName)}/`;
|
||||||
|
return { url, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
downloadGiteaReleaseAsset,
|
||||||
|
giteaToken,
|
||||||
|
useGiteaReleases,
|
||||||
|
getGiteaUpdaterFeedBase,
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
'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,
|
||||||
|
};
|
||||||
@@ -35,6 +35,8 @@
|
|||||||
"renderer.js",
|
"renderer.js",
|
||||||
"styles.css",
|
"styles.css",
|
||||||
"default-launcher.json",
|
"default-launcher.json",
|
||||||
|
"lib/gitea-release.js",
|
||||||
|
"lib/patch-manifest.js",
|
||||||
"lib/**/*"
|
"lib/**/*"
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Build patch-manifest.json for a release (same names as files[].source in launcher.json).
|
||||||
|
*
|
||||||
|
* Usage (from a folder containing the patch binaries):
|
||||||
|
* node generate-patch-manifest.js v0.9.0-client patch-Z.MPQ Wow-patched.exe
|
||||||
|
*
|
||||||
|
* Prints JSON to stdout — redirect to file:
|
||||||
|
* node generate-patch-manifest.js v0.9.0-client patch-Z.MPQ Wow-patched.exe > patch-manifest.json
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const version = process.argv[2];
|
||||||
|
const names = process.argv.slice(3);
|
||||||
|
if (!version || names.length === 0) {
|
||||||
|
console.error('Usage: generate-patch-manifest.js <version-label> <file1> [file2 ...]');
|
||||||
|
console.error(' Example: generate-patch-manifest.js v0.9.0-client patch-Z.MPQ Wow-patched.exe');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = { version, files: {} };
|
||||||
|
for (const f of names) {
|
||||||
|
const base = path.basename(f);
|
||||||
|
const buf = fs.readFileSync(f);
|
||||||
|
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||||
|
out.files[base] = { sha256 };
|
||||||
|
}
|
||||||
|
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
||||||
Reference in New Issue
Block a user