From 6c4d7244c33b64dbd6b5746cbe7198f76718da9e Mon Sep 17 00:00:00 2001 From: Docker Build Date: Sun, 10 May 2026 19:08:55 -0500 Subject: [PATCH] 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 --- .../lib/gitea-release.js | 100 ++++++++++++ .../lib/patch-manifest.js | 144 ++++++++++++++++++ .../fractured-launcher-electron/package.json | 2 + .../scripts/generate-patch-manifest.js | 32 ++++ 4 files changed, 278 insertions(+) create mode 100644 tools/fractured-launcher-electron/lib/gitea-release.js create mode 100644 tools/fractured-launcher-electron/lib/patch-manifest.js create mode 100644 tools/fractured-launcher-electron/scripts/generate-patch-manifest.js diff --git a/tools/fractured-launcher-electron/lib/gitea-release.js b/tools/fractured-launcher-electron/lib/gitea-release.js new file mode 100644 index 0000000..d202462 --- /dev/null +++ b/tools/fractured-launcher-electron/lib/gitea-release.js @@ -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, +}; diff --git a/tools/fractured-launcher-electron/lib/patch-manifest.js b/tools/fractured-launcher-electron/lib/patch-manifest.js new file mode 100644 index 0000000..72d0178 --- /dev/null +++ b/tools/fractured-launcher-electron/lib/patch-manifest.js @@ -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, +}; diff --git a/tools/fractured-launcher-electron/package.json b/tools/fractured-launcher-electron/package.json index 3adbd44..799d5dc 100644 --- a/tools/fractured-launcher-electron/package.json +++ b/tools/fractured-launcher-electron/package.json @@ -35,6 +35,8 @@ "renderer.js", "styles.css", "default-launcher.json", + "lib/gitea-release.js", + "lib/patch-manifest.js", "lib/**/*" ], "win": { diff --git a/tools/fractured-launcher-electron/scripts/generate-patch-manifest.js b/tools/fractured-launcher-electron/scripts/generate-patch-manifest.js new file mode 100644 index 0000000..ac6e66d --- /dev/null +++ b/tools/fractured-launcher-electron/scripts/generate-patch-manifest.js @@ -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 [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`);