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>
This commit is contained in:
Docker Build
2026-05-10 22:04:48 -05:00
parent f409ffad12
commit 9cef99f0ff
9 changed files with 193 additions and 29 deletions
+2 -1
View File
@@ -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. - **`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. - **`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). - **`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`**.
@@ -21,14 +21,7 @@
"source": "patch-manifest.json", "source": "patch-manifest.json",
"from_release": true "from_release": true
}, },
"files": [ "files": [],
{
"source": "Wow-patched.exe",
"dest": "Wow.exe",
"backup": true,
"from_release": true
}
],
"realmlist": { "realmlist": {
"enabled": true, "enabled": true,
"line": "set realmlist fracturedwow.ddns.net:47497", "line": "set realmlist fracturedwow.ddns.net:47497",
@@ -5,14 +5,16 @@ const fs = require('fs').promises;
const { normalizeWinGameDir } = require('./win-game-dir'); const { normalizeWinGameDir } = require('./win-game-dir');
/** Sources no longer shipped; drop from merged files so old launcher.json does not keep fetching them. */ /** 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) { function mergeFilesList(defaults, user) {
const raw = const dep = (e) => DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim());
Array.isArray(user.files) && user.files.length ? user.files.map((e) => ({ ...e })) : defaults.files.map((e) => ({ ...e })); if (Array.isArray(user.files) && user.files.length) {
const filtered = raw.filter((e) => !DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim())); const filtered = user.files.map((e) => ({ ...e })).filter((e) => !dep(e));
if (filtered.length) return filtered; if (filtered.length) return filtered;
return defaults.files.map((e) => ({ ...e })); }
const defList = Array.isArray(defaults.files) ? defaults.files : [];
return defList.map((e) => ({ ...e })).filter((e) => !dep(e));
} }
function userFilesContainDeprecated(user) { function userFilesContainDeprecated(user) {
@@ -33,7 +33,7 @@ function useGiteaReleases(cfg) {
return !!(String(g.base_url || '').trim() && String(g.owner || '').trim() && String(g.repo || '').trim()); 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 api = giteaApiBase(cfg);
const { owner, repo } = cfg.gitea; const { owner, repo } = cfg.gitea;
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest'; 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)'; 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)}`); 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 || []; const list = rel.attachments || rel.assets || [];
let downloadUrl = ''; let downloadUrl = '';
for (const a of list) { for (const a of list) {
@@ -101,6 +112,8 @@ async function getGiteaUpdaterFeedBase(cfg) {
module.exports = { module.exports = {
downloadGiteaReleaseAsset, downloadGiteaReleaseAsset,
fetchGiteaReleaseRecord,
listGiteaReleaseAttachmentNames,
giteaToken, giteaToken,
useGiteaReleases, useGiteaReleases,
getGiteaUpdaterFeedBase, getGiteaUpdaterFeedBase,
@@ -3,7 +3,7 @@
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { githubToken } = require('./github-token'); 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'); const { fetchToFile, downloadBodyToFile, fetchOrThrow } = require('./http-download');
function encodeRepoPath(repoPath) { function encodeRepoPath(repoPath) {
@@ -65,10 +65,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
throw new Error(`unexpected GitHub response for ${repoPath}`); throw new Error(`unexpected GitHub response for ${repoPath}`);
} }
async function downloadReleaseAsset(cfg, assetName, destPath) { async function fetchGitHubReleaseJson(cfg) {
if (useGiteaReleases(cfg)) {
return downloadGiteaReleaseAsset(cfg, assetName, destPath);
}
const token = githubToken(cfg); const token = githubToken(cfg);
const tag = (cfg.github.release_tag || 'latest').trim() || 'latest'; const tag = (cfg.github.release_tag || 'latest').trim() || 'latest';
const { owner, repo } = cfg.github; 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)'; 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)}`); 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 || []; const assets = rel.assets || [];
let assetURL = ''; let assetURL = '';
for (const a of assets) { for (const a of assets) {
@@ -118,4 +132,10 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
await downloadBodyToFile(dl, destPath); await downloadBodyToFile(dl, destPath);
} }
module.exports = { downloadGitHubRepoFile, downloadReleaseAsset, encodeRepoPath }; module.exports = {
downloadGitHubRepoFile,
downloadReleaseAsset,
encodeRepoPath,
fetchGitHubReleaseJson,
listReleaseAttachmentNames,
};
@@ -75,8 +75,10 @@ async function loadManifest(cfg) {
*/ */
async function patchesMatchManifest(cfg, manifest, onStatus) { async function patchesMatchManifest(cfg, manifest, onStatus) {
if (!validateManifest(manifest)) return false; if (!validateManifest(manifest)) return false;
const { buildResolvedReleaseFiles } = require('./release-sync');
const entries = await buildResolvedReleaseFiles(cfg, manifest);
const gameDir = cfg.game_dir; const gameDir = cfg.game_dir;
for (const entry of cfg.files || []) { for (const entry of entries) {
if (!entry.from_release) continue; if (!entry.from_release) continue;
const spec = manifest.files[entry.source]; const spec = manifest.files[entry.source];
if (!spec || !spec.sha256) return false; if (!spec || !spec.sha256) return false;
@@ -98,7 +100,9 @@ async function patchesMatchManifest(cfg, manifest, onStatus) {
async function verifyInstalledAgainstManifest(cfg, manifest) { async function verifyInstalledAgainstManifest(cfg, manifest) {
if (!validateManifest(manifest)) return; 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; if (!entry.from_release) continue;
const spec = manifest.files[entry.source]; const spec = manifest.files[entry.source];
if (!spec || !spec.sha256) { if (!spec || !spec.sha256) {
@@ -119,8 +123,10 @@ async function verifyInstalledAgainstManifest(cfg, manifest) {
async function recordPatchState(cfg, manifest) { async function recordPatchState(cfg, manifest) {
if (!validateManifest(manifest)) return; if (!validateManifest(manifest)) return;
const { buildResolvedReleaseFiles } = require('./release-sync');
const entries = await buildResolvedReleaseFiles(cfg, manifest);
const shas = {}; const shas = {};
for (const entry of cfg.files || []) { for (const entry of entries) {
if (!entry.from_release) continue; if (!entry.from_release) continue;
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean); const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
const destAbs = path.join(cfg.game_dir, ...parts); const destAbs = path.join(cfg.game_dir, ...parts);
@@ -5,6 +5,8 @@ const fs = require('fs').promises;
const fsSync = require('fs'); const fsSync = require('fs');
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github'); const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
const { normalizeWinGameDir } = require('./win-game-dir'); const { normalizeWinGameDir } = require('./win-game-dir');
const { loadManifest } = require('./patch-manifest');
const { buildResolvedReleaseFiles } = require('./release-sync');
function pad2(n) { function pad2(n) {
return String(n).padStart(2, '0'); return String(n).padStart(2, '0');
@@ -94,7 +96,12 @@ async function applyRealmlist(cfg) {
} }
async function applyPatches(cfg, onStatus) { 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}`); if (onStatus) onStatus(`Updating ${f.dest}`);
try { try {
await installFile(cfg, f); await installFile(cfg, f);
@@ -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,
};
@@ -1,6 +1,6 @@
{ {
"name": "fractured-launcher-electron", "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", "description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
"main": "main.js", "main": "main.js",
"repository": { "repository": {