From 9cef99f0ff91ddceebfdd677802aed7bf89bde25 Mon Sep 17 00:00:00 2001 From: Docker Build Date: Sun, 10 May 2026 22:04:48 -0500 Subject: [PATCH] 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 --- tools/fractured-launcher-electron/README.md | 3 +- .../default-launcher.json | 9 +- .../lib/config-store.js | 14 +- .../lib/gitea-release.js | 17 ++- .../fractured-launcher-electron/lib/github.js | 34 ++++- .../lib/patch-manifest.js | 12 +- .../fractured-launcher-electron/lib/patch.js | 9 +- .../lib/release-sync.js | 122 ++++++++++++++++++ .../fractured-launcher-electron/package.json | 2 +- 9 files changed, 193 insertions(+), 29 deletions(-) create mode 100644 tools/fractured-launcher-electron/lib/release-sync.js diff --git a/tools/fractured-launcher-electron/README.md b/tools/fractured-launcher-electron/README.md index 7149170..ef93dfd 100644 --- a/tools/fractured-launcher-electron/README.md +++ b/tools/fractured-launcher-electron/README.md @@ -169,4 +169,5 @@ Schema is defined by **`default-launcher.json`** (shipped in the app; first run - **`gitea`**: **`base_url`**, **`owner`**, **`repo`**, **`release_tag`**, **`token_env`** — when **`base_url`** is set (and owner/repo set), **`from_release`** downloads and (with token if needed) the **generic** updater feed use **Gitea**. **Required** for players if your CI mirrors patches/launchers to Gitea only. - **`github`**: used for **non-release** repo paths (`from_release`: false) and for **GitHub** **`from_release`** when **`gitea.base_url`** is empty. - **`patch_manifest`**: **`enabled`**, **`source`** (default `patch-manifest.json`), **`from_release`** — checksum-based skip + verify (see above). -- **`files`**, **`realmlist`**, **`auth`**, **`launch`**. +- **`files`**: default **`[]`**. **Download updates** resolves what to pull in order: (**1**) non-empty **`files`** if you set explicit **`source`** → **`dest`** pairs; (**2**) else each key in **`patch-manifest.json`** on the release (recommended); (**3**) else release attachments except launcher artifacts (`Fractured-Launcher*`, `*.blockmap`, `latest*.yml`, `.AppImage`, `patch-manifest.json`): **`.MPQ`** → **`Data/`**, one **`.exe`** → **`launch.exe`**. Multiple `.exe` attachments require a manifest. Legacy **`Wow-patched.exe`** entries are removed when merging **`launcher.json`**. +- **`realmlist`**, **`auth`**, **`launch`**. diff --git a/tools/fractured-launcher-electron/default-launcher.json b/tools/fractured-launcher-electron/default-launcher.json index b084a24..5554fc0 100644 --- a/tools/fractured-launcher-electron/default-launcher.json +++ b/tools/fractured-launcher-electron/default-launcher.json @@ -21,14 +21,7 @@ "source": "patch-manifest.json", "from_release": true }, - "files": [ - { - "source": "Wow-patched.exe", - "dest": "Wow.exe", - "backup": true, - "from_release": true - } - ], + "files": [], "realmlist": { "enabled": true, "line": "set realmlist fracturedwow.ddns.net:47497", diff --git a/tools/fractured-launcher-electron/lib/config-store.js b/tools/fractured-launcher-electron/lib/config-store.js index edd8d49..7df6c9d 100644 --- a/tools/fractured-launcher-electron/lib/config-store.js +++ b/tools/fractured-launcher-electron/lib/config-store.js @@ -5,14 +5,16 @@ const fs = require('fs').promises; const { normalizeWinGameDir } = require('./win-game-dir'); /** Sources no longer shipped; drop from merged files so old launcher.json does not keep fetching them. */ -const DEPRECATED_FILE_SOURCES = new Set(['patch-Z.MPQ']); +const DEPRECATED_FILE_SOURCES = new Set(['patch-Z.MPQ', 'Wow-patched.exe']); function mergeFilesList(defaults, user) { - const raw = - Array.isArray(user.files) && user.files.length ? user.files.map((e) => ({ ...e })) : defaults.files.map((e) => ({ ...e })); - const filtered = raw.filter((e) => !DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim())); - if (filtered.length) return filtered; - return defaults.files.map((e) => ({ ...e })); + const dep = (e) => DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim()); + if (Array.isArray(user.files) && user.files.length) { + const filtered = user.files.map((e) => ({ ...e })).filter((e) => !dep(e)); + if (filtered.length) return filtered; + } + const defList = Array.isArray(defaults.files) ? defaults.files : []; + return defList.map((e) => ({ ...e })).filter((e) => !dep(e)); } function userFilesContainDeprecated(user) { diff --git a/tools/fractured-launcher-electron/lib/gitea-release.js b/tools/fractured-launcher-electron/lib/gitea-release.js index 4eddf17..3b53574 100644 --- a/tools/fractured-launcher-electron/lib/gitea-release.js +++ b/tools/fractured-launcher-electron/lib/gitea-release.js @@ -33,7 +33,7 @@ function useGiteaReleases(cfg) { return !!(String(g.base_url || '').trim() && String(g.owner || '').trim() && String(g.repo || '').trim()); } -async function downloadGiteaReleaseAsset(cfg, assetName, destPath) { +async function fetchGiteaReleaseRecord(cfg) { const api = giteaApiBase(cfg); const { owner, repo } = cfg.gitea; const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest'; @@ -54,7 +54,18 @@ async function downloadGiteaReleaseAsset(cfg, assetName, destPath) { 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); + return JSON.parse(text); +} + +async function listGiteaReleaseAttachmentNames(cfg) { + const rel = await fetchGiteaReleaseRecord(cfg); + const list = rel.attachments || rel.assets || []; + return list.map((x) => x.name).filter(Boolean); +} + +async function downloadGiteaReleaseAsset(cfg, assetName, destPath) { + const token = giteaToken(cfg); + const rel = await fetchGiteaReleaseRecord(cfg); const list = rel.attachments || rel.assets || []; let downloadUrl = ''; for (const a of list) { @@ -101,6 +112,8 @@ async function getGiteaUpdaterFeedBase(cfg) { module.exports = { downloadGiteaReleaseAsset, + fetchGiteaReleaseRecord, + listGiteaReleaseAttachmentNames, giteaToken, useGiteaReleases, getGiteaUpdaterFeedBase, diff --git a/tools/fractured-launcher-electron/lib/github.js b/tools/fractured-launcher-electron/lib/github.js index 10c0e51..53f6a60 100644 --- a/tools/fractured-launcher-electron/lib/github.js +++ b/tools/fractured-launcher-electron/lib/github.js @@ -3,7 +3,7 @@ const path = require('path'); const fs = require('fs').promises; const { githubToken } = require('./github-token'); -const { downloadGiteaReleaseAsset, useGiteaReleases } = require('./gitea-release'); +const { downloadGiteaReleaseAsset, useGiteaReleases, listGiteaReleaseAttachmentNames } = require('./gitea-release'); const { fetchToFile, downloadBodyToFile, fetchOrThrow } = require('./http-download'); function encodeRepoPath(repoPath) { @@ -65,10 +65,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) { throw new Error(`unexpected GitHub response for ${repoPath}`); } -async function downloadReleaseAsset(cfg, assetName, destPath) { - if (useGiteaReleases(cfg)) { - return downloadGiteaReleaseAsset(cfg, assetName, destPath); - } +async function fetchGitHubReleaseJson(cfg) { const token = githubToken(cfg); const tag = (cfg.github.release_tag || 'latest').trim() || 'latest'; const { owner, repo } = cfg.github; @@ -89,7 +86,24 @@ async function downloadReleaseAsset(cfg, assetName, destPath) { if (res.status === 401 || res.status === 403) hint = ' (set GITHUB_TOKEN or token_env PAT)'; throw new Error(`releases list ${res.status}${hint}: ${text.slice(0, 600)}`); } - const rel = JSON.parse(text); + return JSON.parse(text); +} + +async function listReleaseAttachmentNames(cfg) { + if (useGiteaReleases(cfg)) { + return listGiteaReleaseAttachmentNames(cfg); + } + const rel = await fetchGitHubReleaseJson(cfg); + const assets = rel.assets || []; + return assets.map((a) => a.name).filter(Boolean); +} + +async function downloadReleaseAsset(cfg, assetName, destPath) { + if (useGiteaReleases(cfg)) { + return downloadGiteaReleaseAsset(cfg, assetName, destPath); + } + const token = githubToken(cfg); + const rel = await fetchGitHubReleaseJson(cfg); const assets = rel.assets || []; let assetURL = ''; for (const a of assets) { @@ -118,4 +132,10 @@ async function downloadReleaseAsset(cfg, assetName, destPath) { await downloadBodyToFile(dl, destPath); } -module.exports = { downloadGitHubRepoFile, downloadReleaseAsset, encodeRepoPath }; +module.exports = { + downloadGitHubRepoFile, + downloadReleaseAsset, + encodeRepoPath, + fetchGitHubReleaseJson, + listReleaseAttachmentNames, +}; diff --git a/tools/fractured-launcher-electron/lib/patch-manifest.js b/tools/fractured-launcher-electron/lib/patch-manifest.js index 72d0178..b656435 100644 --- a/tools/fractured-launcher-electron/lib/patch-manifest.js +++ b/tools/fractured-launcher-electron/lib/patch-manifest.js @@ -75,8 +75,10 @@ async function loadManifest(cfg) { */ 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 cfg.files || []) { + for (const entry of entries) { if (!entry.from_release) continue; const spec = manifest.files[entry.source]; if (!spec || !spec.sha256) return false; @@ -98,7 +100,9 @@ async function patchesMatchManifest(cfg, manifest, onStatus) { async function verifyInstalledAgainstManifest(cfg, manifest) { if (!validateManifest(manifest)) return; - for (const entry of cfg.files || []) { + 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) { @@ -119,8 +123,10 @@ async function verifyInstalledAgainstManifest(cfg, manifest) { 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 cfg.files || []) { + 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); diff --git a/tools/fractured-launcher-electron/lib/patch.js b/tools/fractured-launcher-electron/lib/patch.js index be284a0..ec40efe 100644 --- a/tools/fractured-launcher-electron/lib/patch.js +++ b/tools/fractured-launcher-electron/lib/patch.js @@ -5,6 +5,8 @@ const fs = require('fs').promises; const fsSync = require('fs'); const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github'); const { normalizeWinGameDir } = require('./win-game-dir'); +const { loadManifest } = require('./patch-manifest'); +const { buildResolvedReleaseFiles } = require('./release-sync'); function pad2(n) { return String(n).padStart(2, '0'); @@ -94,7 +96,12 @@ async function applyRealmlist(cfg) { } async function applyPatches(cfg, onStatus) { - for (const f of cfg.files || []) { + let manifest = null; + if (cfg.patch_manifest && cfg.patch_manifest.enabled) { + manifest = await loadManifest(cfg); + } + const entries = await buildResolvedReleaseFiles(cfg, manifest); + for (const f of entries) { if (onStatus) onStatus(`Updating ${f.dest} …`); try { await installFile(cfg, f); diff --git a/tools/fractured-launcher-electron/lib/release-sync.js b/tools/fractured-launcher-electron/lib/release-sync.js new file mode 100644 index 0000000..8f390b5 --- /dev/null +++ b/tools/fractured-launcher-electron/lib/release-sync.js @@ -0,0 +1,122 @@ +'use strict'; + +const path = require('path'); +const { listReleaseAttachmentNames } = require('./github'); + +/** Legacy launcher.json rows — ignored when merging explicit files. */ +const DEPRECATED_SOURCES = new Set(['patch-Z.MPQ', 'Wow-patched.exe']); + +function filterExplicitFiles(files) { + if (!Array.isArray(files)) return []; + return files + .filter((e) => e && String(e.source || '').trim()) + .filter((e) => !DEPRECATED_SOURCES.has(String(e.source).trim())) + .map((e) => ({ + source: String(e.source).trim(), + dest: String(e.dest || '').trim(), + backup: e.backup !== false, + from_release: e.from_release !== false, + })) + .filter((e) => e.dest); +} + +function manifestLooksUsable(m) { + return !!(m && m.files && typeof m.files === 'object' && Object.keys(m.files).length > 0); +} + +/** Launcher / updater attachments — never copied into the WoW folder. */ +function isExcludedFromGameSync(fileName) { + const n = String(fileName || ''); + const lower = n.toLowerCase(); + if (lower === 'patch-manifest.json') return true; + if (/^fractured-launcher/i.test(n)) return true; + if (/\.blockmap$/i.test(n)) return true; + if (/^latest.*\.ya?ml$/i.test(n) || lower === 'latest.yml') return true; + if (lower.includes('builder-debug')) return true; + if (/\.appimage$/i.test(n)) return true; + return false; +} + +function destForReleaseSource(source, cfg) { + const base = path.basename(String(source || '')); + if (/\.mpq$/i.test(base)) return `Data/${base}`; + if (/\.exe$/i.test(base)) return (cfg.launch && cfg.launch.exe) || 'Wow.exe'; + return base; +} + +/** + * Explicit `files` in config wins. Otherwise use patch-manifest keys if present, + * else discover attachments on the release (excluding launcher artifacts). + */ +async function buildResolvedReleaseFiles(cfg, manifestMaybeNull) { + const explicit = filterExplicitFiles(cfg.files); + if (explicit.length) return explicit; + + const manifest = manifestMaybeNull; + if (manifestLooksUsable(manifest)) { + const keys = Object.keys(manifest.files).filter((k) => k && !isExcludedFromGameSync(k)); + if (!keys.length) { + throw new Error('patch-manifest.json has no file entries — add files or attach assets to the release.'); + } + return keys.map((source) => ({ + source, + dest: destForReleaseSource(source, cfg), + backup: true, + from_release: true, + })); + } + + const names = await listReleaseAttachmentNames(cfg); + const game = names.filter((n) => n && !isExcludedFromGameSync(n)); + if (!game.length) { + throw new Error( + 'No patch files on this release (after excluding launcher installers). ' + + 'Attach MPQ/exe assets or ship patch-manifest.json listing filenames.' + ); + } + + const exes = game.filter((n) => /\.exe$/i.test(n)); + const mpqs = game.filter((n) => /\.mpq$/i.test(n)); + const rest = game.filter((n) => !/\.(exe|mpq)$/i.test(n)); + + if (exes.length > 1) { + throw new Error( + `Release has multiple .exe files (${exes.join(', ')}). ` + + 'Remove extras or publish patch-manifest.json with the exact filenames to install.' + ); + } + + const out = []; + for (const n of mpqs) { + out.push({ + source: n, + dest: path.posix.join('Data', path.basename(n)), + backup: true, + from_release: true, + }); + } + if (exes.length === 1) { + out.push({ + source: exes[0], + dest: (cfg.launch && cfg.launch.exe) || 'Wow.exe', + backup: true, + from_release: true, + }); + } + for (const n of rest) { + out.push({ + source: n, + dest: path.basename(n), + backup: true, + from_release: true, + }); + } + return out; +} + +module.exports = { + buildResolvedReleaseFiles, + filterExplicitFiles, + isExcludedFromGameSync, + DEPRECATED_SOURCES, +}; diff --git a/tools/fractured-launcher-electron/package.json b/tools/fractured-launcher-electron/package.json index 01630c6..c281431 100644 --- a/tools/fractured-launcher-electron/package.json +++ b/tools/fractured-launcher-electron/package.json @@ -1,6 +1,6 @@ { "name": "fractured-launcher-electron", - "version": "1.0.6", + "version": "1.0.7", "description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update", "main": "main.js", "repository": {